Compare commits

..

34 Commits

Author SHA1 Message Date
Ludovic BOUÉ
0a8a1ff345 Update test_sensor.ambr to use list for aliases and add object_id_base for last change attributes 2026-03-19 22:50:10 +01:00
Ludovic BOUÉ
43b2e26993 Refactor device_to_ha mapping to use get method for SetpointChangeSource 2026-03-19 22:44:30 +01:00
Ludovic BOUÉ
68bea745d4 Merge branch 'dev' into setpoint_change_source 2026-03-19 22:35:53 +01:00
Ludovic BOUÉ
9f876757f6 Merge branch 'dev' into setpoint_change_source 2026-03-11 19:30:04 +01:00
Ludovic BOUÉ
28f70fab8d Add setpoint_change_source icon with states for external, manual, and schedule 2026-01-13 20:23:07 +00:00
Ludovic BOUÉ
289490faa3 Refactor setpoint_change_timestamp device_to_ha conversion to use matter_epoch_seconds_to_utc 2026-01-12 20:57:39 +00:00
Ludovic BOUÉ
dea46f7b2e Add tests for Eve Thermo v5 SetpointChangeSource, timestamp, and amount sensors 2026-01-12 20:54:52 +00:00
Ludovic BOUÉ
82e3221126 Update Matter Eve Thermo sensor entries to reflect last change and change amount attributes 2026-01-12 19:28:15 +00:00
Ludovic BOUÉ
47e8fbc1ed Add Matter Eve Thermo 20ECD1701 sensor entries and update mock thermostat configurations 2026-01-12 19:25:10 +00:00
Ludovic BOUÉ
0428d0b97f Merge branch 'dev' into setpoint_change_source 2026-01-12 20:19:09 +01:00
Ludovic BOUÉ
45344c04c1 Refactor setpoint change source mapping and add utility functions for Matter epoch conversion 2026-01-12 19:14:54 +00:00
Ludovic BOUÉ
c472b6ac5e Add support for RoomAirConditioner device type 2025-12-01 15:12:33 +00:00
Ludovic BOUÉ
58f533feb6 Add device_type attribute for Thermostat sensors 2025-11-30 21:43:43 +01:00
Ludovic BOUÉ
0af8c8fd8c Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-30 21:37:01 +01:00
Ludovic BOUÉ
b9d6c3b9fe Update homeassistant/components/matter/strings.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-30 21:36:08 +01:00
Ludovic BOUÉ
b700940bb9 Merge branch 'dev' into setpoint_change_source 2025-11-30 21:31:19 +01:00
Ludovic BOUÉ
3b73f6d37e Update thermostat setpoint change timestamp to January 1, 2025 2025-11-30 20:21:45 +00:00
Ludovic BOUÉ
2812bb21da Add offset for Matter 2000 epoch in timestamp conversion 2025-11-30 20:20:13 +00:00
Ludovic BOUÉ
5d474675e8 Update mock thermostat state timestamp to January 1, 2025 2025-11-30 20:15:31 +00:00
Ludovic BOUÉ
ea7bcf6cda Update mock thermostat JSON to correct timestamp for attribute 1/513/50 2025-11-30 20:11:19 +00:00
Ludovic BOUÉ
725bd3d671 Add mock thermostat entity and state snapshots for temperature display mode 2025-11-21 12:38:04 +00:00
Ludovic BOUÉ
cfc4fa6342 Merge branch 'dev' into setpoint_change_source 2025-11-21 13:35:31 +01:00
Ludovic BOUÉ
b650e71660 Update mock thermostat snapshots with new attributes and state values 2025-11-18 18:05:29 +00:00
Ludovic BOUÉ
9ddf15e348 Update mock thermostat JSON with additional attributes and values 2025-11-18 18:03:46 +00:00
Ludovic BOUÉ
15082f9111 Merge branch 'dev' into setpoint_change_source 2025-11-18 16:45:05 +01:00
Ludovic BOUÉ
12f16611ff Rename mock thermostat entity IDs and friendly names in snapshots for consistency 2025-11-18 15:30:39 +00:00
Ludovic BOUÉ
8041be3d08 Merge branch 'dev' into setpoint_change_source 2025-11-18 14:08:38 +01:00
Ludovic BOUÉ
40b021e755 Add tests for Thermostat SetpointChangeSource, Timestamp, and Amount sensors 2025-11-18 13:02:44 +00:00
Ludovic BOUÉ
aab57eda96 Update mock thermostat product name to "Mock Thermostat" 2025-11-18 13:00:26 +00:00
Ludovic BOUÉ
f0dd37caa5 Add mock thermostat sensors and states for testing 2025-11-18 12:49:32 +00:00
Ludovic BOUÉ
662b178495 Remove unused attribute from thermostat fixture 2025-11-18 12:48:56 +00:00
Ludovic BOUÉ
cb3d30884a Add mock thermostat fixture for integration tests 2025-11-18 12:48:21 +00:00
Ludovic BOUÉ
49e6f20372 Add Setpoint Change Source timestamp and amount sensors with localization strings 2025-11-18 12:39:28 +00:00
Ludovic BOUÉ
75d02661eb Add Setpoint Change Source sensor and localization strings 2025-11-14 17:19:28 +00:00
1310 changed files with 10479 additions and 69973 deletions

View File

@@ -1,12 +1,6 @@
<!-- Automatically generated by gen_copilot_instructions.py, do not edit --> <!-- Automatically generated by gen_copilot_instructions.py, do not edit -->
# Copilot code review instructions
- Start review comments with a short, one-sentence summary of the suggested fix.
- Do not add comments about code style, formatting or linting issues.
# GitHub Copilot & Claude Code Instructions # GitHub Copilot & Claude Code Instructions
This repository contains the core of Home Assistant, a Python 3 based home automation application. This repository contains the core of Home Assistant, a Python 3 based home automation application.

View File

@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60 UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true" UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker # Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.02.0" BASE_IMAGE_VERSION: "2026.01.0"
ARCHITECTURES: '["amd64", "aarch64"]' ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {} permissions: {}
@@ -112,7 +112,7 @@ jobs:
- name: Download nightly wheels of frontend - name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19 uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend repo: home-assistant/frontend
@@ -123,7 +123,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@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19 uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package repo: OHF-Voice/intents-package
@@ -224,6 +224,7 @@ jobs:
matrix: matrix:
machine: machine:
- generic-x86-64 - generic-x86-64
- intel-nuc
- khadas-vim3 - khadas-vim3
- odroid-c2 - odroid-c2
- odroid-c4 - odroid-c4
@@ -247,6 +248,10 @@ jobs:
- machine: qemux86-64 - machine: qemux86-64
arch: amd64 arch: amd64
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
- machine: intel-nuc
arch: amd64
runs-on: ubuntu-24.04
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

View File

@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 3 CACHE_VERSION: 3
UV_CACHE_VERSION: 1 UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.5" HA_SHORT_VERSION: "2026.4"
ADDITIONAL_PYTHON_VERSIONS: "[]" ADDITIONAL_PYTHON_VERSIONS: "[]"
# 10.3 is the oldest supported version # 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
@@ -120,7 +120,7 @@ jobs:
run: | run: |
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
- name: Filter for core changes - name: Filter for core changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # 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@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: integrations id: integrations
with: with:
filters: .integration_paths.yaml filters: .integration_paths.yaml
@@ -280,7 +280,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json" echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek - name: Run prek
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0 uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
env: env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github RUFF_OUTPUT_FORMAT: github
@@ -301,7 +301,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0 uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
with: with:
extra-args: --all-files zizmor extra-args: --all-files zizmor
@@ -364,7 +364,7 @@ jobs:
echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
key: >- key: >-
@@ -372,7 +372,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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: ${{ env.UV_CACHE_DIR }} path: ${{ env.UV_CACHE_DIR }}
key: >- key: >-
@@ -384,7 +384,7 @@ jobs:
env.HA_SHORT_VERSION }}- env.HA_SHORT_VERSION }}-
- name: Check if apt cache exists - name: Check if apt cache exists
id: cache-apt-check id: cache-apt-check
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }} lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: | path: |
@@ -430,7 +430,7 @@ jobs:
fi fi
- name: Save apt cache - name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true' if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -484,7 +484,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true' && github.event.inputs.audit-licenses-only != 'true'
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -515,7 +515,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python virtual environment - name: Restore full Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -552,7 +552,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python virtual environment - name: Restore full Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -643,7 +643,7 @@ jobs:
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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -694,7 +694,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python virtual environment - name: Restore full Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -747,7 +747,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python virtual environment - name: Restore full Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -804,7 +804,7 @@ jobs:
echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python virtual environment - name: Restore full Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -812,7 +812,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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: .mypy_cache path: .mypy_cache
key: >- key: >-
@@ -854,7 +854,7 @@ jobs:
- base - base
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -887,7 +887,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python virtual environment - name: Restore full Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -930,7 +930,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }} group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -964,7 +964,7 @@ jobs:
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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1080,7 +1080,7 @@ jobs:
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }} mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -1115,7 +1115,7 @@ jobs:
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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1238,7 +1238,7 @@ jobs:
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }} postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -1275,7 +1275,7 @@ jobs:
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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1392,7 +1392,7 @@ jobs:
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@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with: with:
fail_ci_if_error: true fail_ci_if_error: true
flags: full-suite flags: full-suite
@@ -1421,7 +1421,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }} group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -1455,7 +1455,7 @@ jobs:
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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1563,7 +1563,7 @@ jobs:
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@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with: with:
fail_ci_if_error: true fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1591,7 +1591,7 @@ jobs:
with: with:
pattern: test-results-* pattern: test-results-*
- name: Upload test results to Codecov - name: Upload test results to Codecov
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with: with:
report_type: test_results report_type: test_results
fail_ci_if_error: true fail_ci_if_error: true

View File

@@ -1 +1 @@
3.14.3 3.14.2

View File

@@ -137,7 +137,6 @@ homeassistant.components.calendar.*
homeassistant.components.cambridge_audio.* homeassistant.components.cambridge_audio.*
homeassistant.components.camera.* homeassistant.components.camera.*
homeassistant.components.canary.* homeassistant.components.canary.*
homeassistant.components.casper_glow.*
homeassistant.components.cert_expiry.* homeassistant.components.cert_expiry.*
homeassistant.components.clickatell.* homeassistant.components.clickatell.*
homeassistant.components.clicksend.* homeassistant.components.clicksend.*
@@ -274,12 +273,10 @@ homeassistant.components.homekit_controller.storage
homeassistant.components.homekit_controller.utils homeassistant.components.homekit_controller.utils
homeassistant.components.homewizard.* homeassistant.components.homewizard.*
homeassistant.components.homeworks.* homeassistant.components.homeworks.*
homeassistant.components.hr_energy_qube.*
homeassistant.components.http.* homeassistant.components.http.*
homeassistant.components.huawei_lte.* homeassistant.components.huawei_lte.*
homeassistant.components.humidifier.* homeassistant.components.humidifier.*
homeassistant.components.husqvarna_automower.* homeassistant.components.husqvarna_automower.*
homeassistant.components.huum.*
homeassistant.components.hydrawise.* homeassistant.components.hydrawise.*
homeassistant.components.hyperion.* homeassistant.components.hyperion.*
homeassistant.components.hypontech.* homeassistant.components.hypontech.*
@@ -329,7 +326,6 @@ homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.* homeassistant.components.led_ble.*
homeassistant.components.lektrico.* homeassistant.components.lektrico.*
homeassistant.components.letpot.* homeassistant.components.letpot.*
homeassistant.components.lg_infrared.*
homeassistant.components.libre_hardware_monitor.* homeassistant.components.libre_hardware_monitor.*
homeassistant.components.lidarr.* homeassistant.components.lidarr.*
homeassistant.components.lifx.* homeassistant.components.lifx.*

26
CODEOWNERS generated
View File

@@ -214,8 +214,6 @@ build.json @home-assistant/supervisor
/tests/components/balboa/ @garbled1 @natekspencer /tests/components/balboa/ @garbled1 @natekspencer
/homeassistant/components/bang_olufsen/ @mj23000 /homeassistant/components/bang_olufsen/ @mj23000
/tests/components/bang_olufsen/ @mj23000 /tests/components/bang_olufsen/ @mj23000
/homeassistant/components/battery/ @home-assistant/core
/tests/components/battery/ @home-assistant/core
/homeassistant/components/bayesian/ @HarvsG /homeassistant/components/bayesian/ @HarvsG
/tests/components/bayesian/ @HarvsG /tests/components/bayesian/ @HarvsG
/homeassistant/components/beewi_smartclim/ @alemuro /homeassistant/components/beewi_smartclim/ @alemuro
@@ -275,8 +273,6 @@ build.json @home-assistant/supervisor
/tests/components/cambridge_audio/ @noahhusby /tests/components/cambridge_audio/ @noahhusby
/homeassistant/components/camera/ @home-assistant/core /homeassistant/components/camera/ @home-assistant/core
/tests/components/camera/ @home-assistant/core /tests/components/camera/ @home-assistant/core
/homeassistant/components/casper_glow/ @mikeodr
/tests/components/casper_glow/ @mikeodr
/homeassistant/components/cast/ @emontnemery /homeassistant/components/cast/ @emontnemery
/tests/components/cast/ @emontnemery /tests/components/cast/ @emontnemery
/homeassistant/components/ccm15/ @ocalvo /homeassistant/components/ccm15/ @ocalvo
@@ -739,8 +735,6 @@ build.json @home-assistant/supervisor
/tests/components/homewizard/ @DCSBL /tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer /homeassistant/components/honeywell/ @rdfurman @mkmer
/tests/components/honeywell/ @rdfurman @mkmer /tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015 /homeassistant/components/html5/ @alexyao2015
/tests/components/html5/ @alexyao2015 /tests/components/html5/ @alexyao2015
/homeassistant/components/http/ @home-assistant/core /homeassistant/components/http/ @home-assistant/core
@@ -788,8 +782,6 @@ build.json @home-assistant/supervisor
/tests/components/igloohome/ @keithle888 /tests/components/igloohome/ @keithle888
/homeassistant/components/ign_sismologia/ @exxamalte /homeassistant/components/ign_sismologia/ @exxamalte
/tests/components/ign_sismologia/ @exxamalte /tests/components/ign_sismologia/ @exxamalte
/homeassistant/components/illuminance/ @home-assistant/core
/tests/components/illuminance/ @home-assistant/core
/homeassistant/components/image/ @home-assistant/core /homeassistant/components/image/ @home-assistant/core
/tests/components/image/ @home-assistant/core /tests/components/image/ @home-assistant/core
/homeassistant/components/image_processing/ @home-assistant/core /homeassistant/components/image_processing/ @home-assistant/core
@@ -949,16 +941,12 @@ build.json @home-assistant/supervisor
/tests/components/lektrico/ @lektrico /tests/components/lektrico/ @lektrico
/homeassistant/components/letpot/ @jpelgrom /homeassistant/components/letpot/ @jpelgrom
/tests/components/letpot/ @jpelgrom /tests/components/letpot/ @jpelgrom
/homeassistant/components/lg_infrared/ @home-assistant/core
/tests/components/lg_infrared/ @home-assistant/core
/homeassistant/components/lg_netcast/ @Drafteed @splinter98 /homeassistant/components/lg_netcast/ @Drafteed @splinter98
/tests/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration /homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
/tests/components/lg_thinq/ @LG-ThinQ-Integration /tests/components/lg_thinq/ @LG-ThinQ-Integration
/homeassistant/components/libre_hardware_monitor/ @Sab44 /homeassistant/components/libre_hardware_monitor/ @Sab44
/tests/components/libre_hardware_monitor/ @Sab44 /tests/components/libre_hardware_monitor/ @Sab44
/homeassistant/components/lichess/ @aryanhasgithub
/tests/components/lichess/ @aryanhasgithub
/homeassistant/components/lidarr/ @tkdrob /homeassistant/components/lidarr/ @tkdrob
/tests/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob
/homeassistant/components/liebherr/ @mettolen /homeassistant/components/liebherr/ @mettolen
@@ -1079,8 +1067,6 @@ build.json @home-assistant/supervisor
/tests/components/modern_forms/ @wonderslug /tests/components/modern_forms/ @wonderslug
/homeassistant/components/moehlenhoff_alpha2/ @j-a-n /homeassistant/components/moehlenhoff_alpha2/ @j-a-n
/tests/components/moehlenhoff_alpha2/ @j-a-n /tests/components/moehlenhoff_alpha2/ @j-a-n
/homeassistant/components/moisture/ @home-assistant/core
/tests/components/moisture/ @home-assistant/core
/homeassistant/components/monarch_money/ @jeeftor /homeassistant/components/monarch_money/ @jeeftor
/tests/components/monarch_money/ @jeeftor /tests/components/monarch_money/ @jeeftor
/homeassistant/components/monoprice/ @etsinko @OnFreund /homeassistant/components/monoprice/ @etsinko @OnFreund
@@ -1228,8 +1214,8 @@ build.json @home-assistant/supervisor
/tests/components/onewire/ @garbled1 @epenet /tests/components/onewire/ @garbled1 @epenet
/homeassistant/components/onkyo/ @arturpragacz @eclair4151 /homeassistant/components/onkyo/ @arturpragacz @eclair4151
/tests/components/onkyo/ @arturpragacz @eclair4151 /tests/components/onkyo/ @arturpragacz @eclair4151
/homeassistant/components/onvif/ @jterrace /homeassistant/components/onvif/ @hunterjm @jterrace
/tests/components/onvif/ @jterrace /tests/components/onvif/ @hunterjm @jterrace
/homeassistant/components/open_meteo/ @frenck /homeassistant/components/open_meteo/ @frenck
/tests/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek /homeassistant/components/open_router/ @joostlek
@@ -1319,8 +1305,6 @@ build.json @home-assistant/supervisor
/tests/components/poolsense/ @haemishkyd /tests/components/poolsense/ @haemishkyd
/homeassistant/components/portainer/ @erwindouna /homeassistant/components/portainer/ @erwindouna
/tests/components/portainer/ @erwindouna /tests/components/portainer/ @erwindouna
/homeassistant/components/power/ @home-assistant/core
/tests/components/power/ @home-assistant/core
/homeassistant/components/powerfox/ @klaasnicolaas /homeassistant/components/powerfox/ @klaasnicolaas
/tests/components/powerfox/ @klaasnicolaas /tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerfox_local/ @klaasnicolaas /homeassistant/components/powerfox_local/ @klaasnicolaas
@@ -1606,8 +1590,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/solaredge_local/ @drobtravels @scheric /homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solarlog/ @Ernst79 @dontinelli /homeassistant/components/solarlog/ @Ernst79 @dontinelli
/tests/components/solarlog/ @Ernst79 @dontinelli /tests/components/solarlog/ @Ernst79 @dontinelli
/homeassistant/components/solarman/ @solarmanpv
/tests/components/solarman/ @solarmanpv
/homeassistant/components/solax/ @squishykid @Darsstar /homeassistant/components/solax/ @squishykid @Darsstar
/tests/components/solax/ @squishykid @Darsstar /tests/components/solax/ @squishykid @Darsstar
/homeassistant/components/soma/ @ratsept /homeassistant/components/soma/ @ratsept
@@ -1717,8 +1699,6 @@ build.json @home-assistant/supervisor
/tests/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike
/homeassistant/components/teltonika/ @karlbeecken /homeassistant/components/teltonika/ @karlbeecken
/tests/components/teltonika/ @karlbeecken /tests/components/teltonika/ @karlbeecken
/homeassistant/components/temperature/ @home-assistant/core
/tests/components/temperature/ @home-assistant/core
/homeassistant/components/template/ @Petro31 @home-assistant/core /homeassistant/components/template/ @Petro31 @home-assistant/core
/tests/components/template/ @Petro31 @home-assistant/core /tests/components/template/ @Petro31 @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_fleet/ @Bre77
@@ -1764,8 +1744,6 @@ build.json @home-assistant/supervisor
/tests/components/tomorrowio/ @raman325 @lymanepp /tests/components/tomorrowio/ @raman325 @lymanepp
/homeassistant/components/totalconnect/ @austinmroczek /homeassistant/components/totalconnect/ @austinmroczek
/tests/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek
/homeassistant/components/touchline/ @mnordseth
/tests/components/touchline/ @mnordseth
/homeassistant/components/touchline_sl/ @jnsgruk /homeassistant/components/touchline_sl/ @jnsgruk
/tests/components/touchline_sl/ @jnsgruk /tests/components/touchline_sl/ @jnsgruk
/homeassistant/components/tplink/ @rytilahti @bdraco @sdb9696 /homeassistant/components/tplink/ @rytilahti @bdraco @sdb9696

2
Dockerfile generated
View File

@@ -29,7 +29,7 @@ RUN \
# Verify go2rtc can be executed # Verify go2rtc can be executed
go2rtc --version \ go2rtc --version \
# Install uv # Install uv
&& pip3 install uv==0.11.1 && pip3 install uv==0.10.6
WORKDIR /usr/src WORKDIR /usr/src

View File

@@ -241,18 +241,12 @@ DEFAULT_INTEGRATIONS = {
*BASE_PLATFORMS, *BASE_PLATFORMS,
# #
# Integrations providing triggers and conditions for base platforms: # Integrations providing triggers and conditions for base platforms:
"air_quality",
"battery",
"door", "door",
"garage_door", "garage_door",
"gate", "gate",
"humidity", "humidity",
"illuminance",
"moisture",
"motion", "motion",
"occupancy", "occupancy",
"power",
"temperature",
"window", "window",
} }
DEFAULT_INTEGRATIONS_RECOVERY_MODE = { DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
@@ -468,7 +462,6 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
translation.async_setup(hass) translation.async_setup(hass)
recovery = hass.config.recovery_mode recovery = hass.config.recovery_mode
device_registry.async_setup(hass)
try: try:
await asyncio.gather( await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()), create_eager_task(get_internal_store_manager(hass).async_initialize()),

View File

@@ -1,11 +1,5 @@
{ {
"domain": "lg", "domain": "lg",
"name": "LG", "name": "LG",
"integrations": [ "integrations": ["lg_netcast", "lg_soundbar", "lg_thinq", "webostv"]
"lg_infrared",
"lg_netcast",
"lg_soundbar",
"lg_thinq",
"webostv"
]
} }

View File

@@ -9,6 +9,6 @@
}, },
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["jaraco.abode", "lomond"], "loggers": ["jaraco.abode", "lomond"],
"requirements": ["jaraco.abode==6.4.0"], "requirements": ["jaraco.abode==6.2.1"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
@@ -41,7 +40,6 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
AbodeSensorDescription( AbodeSensorDescription(
key="temperature", key="temperature",
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[ native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[
device.temp_unit device.temp_unit
], ],
@@ -50,14 +48,12 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
AbodeSensorDescription( AbodeSensorDescription(
key="humidity", key="humidity",
device_class=SensorDeviceClass.HUMIDITY, device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda _: PERCENTAGE, native_unit_of_measurement_fn=lambda _: PERCENTAGE,
value_fn=lambda device: cast(float, device.humidity), value_fn=lambda device: cast(float, device.humidity),
), ),
AbodeSensorDescription( AbodeSensorDescription(
key="lux", key="lux",
device_class=SensorDeviceClass.ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda _: LIGHT_LUX, native_unit_of_measurement_fn=lambda _: LIGHT_LUX,
value_fn=lambda device: cast(float, device.lux), value_fn=lambda device: cast(float, device.lux),
), ),

View File

@@ -1,134 +0,0 @@
"""Provides conditions for air quality."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
make_entity_numerical_condition,
make_entity_numerical_condition_with_unit,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import (
CarbonMonoxideConcentrationConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
NitrogenMonoxideConcentrationConverter,
OzoneConcentrationConverter,
SulphurDioxideConcentrationConverter,
UnitlessRatioConverter,
)
def _make_detected_condition(
device_class: BinarySensorDeviceClass,
) -> type[Condition]:
"""Create a detected condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
)
def _make_cleared_condition(
device_class: BinarySensorDeviceClass,
) -> type[Condition]:
"""Create a cleared condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
)
CONDITIONS: dict[str, type[Condition]] = {
# Binary sensor conditions (detected/cleared)
"is_gas_detected": _make_detected_condition(BinarySensorDeviceClass.GAS),
"is_gas_cleared": _make_cleared_condition(BinarySensorDeviceClass.GAS),
"is_co_detected": _make_detected_condition(BinarySensorDeviceClass.CO),
"is_co_cleared": _make_cleared_condition(BinarySensorDeviceClass.CO),
"is_smoke_detected": _make_detected_condition(BinarySensorDeviceClass.SMOKE),
"is_smoke_cleared": _make_cleared_condition(BinarySensorDeviceClass.SMOKE),
# Numerical sensor conditions with unit conversion
"is_co_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"is_ozone_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"is_voc_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"is_voc_ratio_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"is_no_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"is_no2_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"is_so2_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
# Numerical sensor conditions without unit conversion (single-unit device classes)
"is_co2_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"is_pm1_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm25_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm4_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm10_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_n2o_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the air quality conditions."""
return CONDITIONS

View File

@@ -1,449 +0,0 @@
# --- Common condition fields ---
.condition_behavior: &condition_behavior
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
# --- Unit lists for multi-unit pollutants ---
.co_units: &co_units
- "ppb"
- "ppm"
- "mg/m³"
- "μg/m³"
.ozone_units: &ozone_units
- "ppb"
- "ppm"
- "μg/m³"
.voc_units: &voc_units
- "μg/m³"
- "mg/m³"
.voc_ratio_units: &voc_ratio_units
- "ppb"
- "ppm"
.no_units: &no_units
- "ppb"
- "μg/m³"
.no2_units: &no2_units
- "ppb"
- "ppm"
- "μg/m³"
.so2_units: &so2_units
- "ppb"
- "μg/m³"
# --- Entity filter anchors ---
.co_threshold_entity: &co_threshold_entity
- domain: input_number
unit_of_measurement: *co_units
- domain: sensor
device_class: carbon_monoxide
- domain: number
device_class: carbon_monoxide
.co2_threshold_entity: &co2_threshold_entity
- domain: input_number
unit_of_measurement: "ppm"
- domain: sensor
device_class: carbon_dioxide
- domain: number
device_class: carbon_dioxide
.pm1_threshold_entity: &pm1_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm1
- domain: number
device_class: pm1
.pm25_threshold_entity: &pm25_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm25
- domain: number
device_class: pm25
.pm4_threshold_entity: &pm4_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm4
- domain: number
device_class: pm4
.pm10_threshold_entity: &pm10_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm10
- domain: number
device_class: pm10
.ozone_threshold_entity: &ozone_threshold_entity
- domain: input_number
unit_of_measurement: *ozone_units
- domain: sensor
device_class: ozone
- domain: number
device_class: ozone
.voc_threshold_entity: &voc_threshold_entity
- domain: input_number
unit_of_measurement: *voc_units
- domain: sensor
device_class: volatile_organic_compounds
- domain: number
device_class: volatile_organic_compounds
.voc_ratio_threshold_entity: &voc_ratio_threshold_entity
- domain: input_number
unit_of_measurement: *voc_ratio_units
- domain: sensor
device_class: volatile_organic_compounds_parts
- domain: number
device_class: volatile_organic_compounds_parts
.no_threshold_entity: &no_threshold_entity
- domain: input_number
unit_of_measurement: *no_units
- domain: sensor
device_class: nitrogen_monoxide
- domain: number
device_class: nitrogen_monoxide
.no2_threshold_entity: &no2_threshold_entity
- domain: input_number
unit_of_measurement: *no2_units
- domain: sensor
device_class: nitrogen_dioxide
- domain: number
device_class: nitrogen_dioxide
.n2o_threshold_entity: &n2o_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: nitrous_oxide
- domain: number
device_class: nitrous_oxide
.so2_threshold_entity: &so2_threshold_entity
- domain: input_number
unit_of_measurement: *so2_units
- domain: sensor
device_class: sulphur_dioxide
- domain: number
device_class: sulphur_dioxide
# --- Number anchors for single-unit pollutants ---
.co2_threshold_number: &co2_threshold_number
mode: box
unit_of_measurement: "ppm"
.ugm3_threshold_number: &ugm3_threshold_number
mode: box
unit_of_measurement: "μg/m³"
# --- Binary sensor targets ---
.target_gas: &target_gas
entity:
- domain: binary_sensor
device_class: gas
.target_co_binary: &target_co_binary
entity:
- domain: binary_sensor
device_class: carbon_monoxide
.target_smoke: &target_smoke
entity:
- domain: binary_sensor
device_class: smoke
# --- Sensor targets ---
.target_co_sensor: &target_co_sensor
entity:
- domain: sensor
device_class: carbon_monoxide
.target_co2: &target_co2
entity:
- domain: sensor
device_class: carbon_dioxide
.target_pm1: &target_pm1
entity:
- domain: sensor
device_class: pm1
.target_pm25: &target_pm25
entity:
- domain: sensor
device_class: pm25
.target_pm4: &target_pm4
entity:
- domain: sensor
device_class: pm4
.target_pm10: &target_pm10
entity:
- domain: sensor
device_class: pm10
.target_ozone: &target_ozone
entity:
- domain: sensor
device_class: ozone
.target_voc: &target_voc
entity:
- domain: sensor
device_class: volatile_organic_compounds
.target_voc_ratio: &target_voc_ratio
entity:
- domain: sensor
device_class: volatile_organic_compounds_parts
.target_no: &target_no
entity:
- domain: sensor
device_class: nitrogen_monoxide
.target_no2: &target_no2
entity:
- domain: sensor
device_class: nitrogen_dioxide
.target_n2o: &target_n2o
entity:
- domain: sensor
device_class: nitrous_oxide
.target_so2: &target_so2
entity:
- domain: sensor
device_class: sulphur_dioxide
# --- Binary sensor conditions ---
.condition_binary_common: &condition_binary_common
fields:
behavior: *condition_behavior
is_gas_detected:
<<: *condition_binary_common
target: *target_gas
is_gas_cleared:
<<: *condition_binary_common
target: *target_gas
is_co_detected:
<<: *condition_binary_common
target: *target_co_binary
is_co_cleared:
<<: *condition_binary_common
target: *target_co_binary
is_smoke_detected:
<<: *condition_binary_common
target: *target_smoke
is_smoke_cleared:
<<: *condition_binary_common
target: *target_smoke
# --- Numerical sensor conditions with unit conversion ---
is_co_value:
target: *target_co_sensor
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *co_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *co_units
is_ozone_value:
target: *target_ozone
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *ozone_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *ozone_units
is_voc_value:
target: *target_voc
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *voc_units
is_voc_ratio_value:
target: *target_voc_ratio
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_ratio_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *voc_ratio_units
is_no_value:
target: *target_no
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *no_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *no_units
is_no2_value:
target: *target_no2
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *no2_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *no2_units
is_so2_value:
target: *target_so2
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *so2_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *so2_units
# --- Numerical sensor conditions without unit conversion ---
is_co2_value:
target: *target_co2
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *co2_threshold_entity
mode: is
number: *co2_threshold_number
is_pm1_value:
target: *target_pm1
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm1_threshold_entity
mode: is
number: *ugm3_threshold_number
is_pm25_value:
target: *target_pm25
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm25_threshold_entity
mode: is
number: *ugm3_threshold_number
is_pm4_value:
target: *target_pm4
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm4_threshold_entity
mode: is
number: *ugm3_threshold_number
is_pm10_value:
target: *target_pm10
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm10_threshold_entity
mode: is
number: *ugm3_threshold_number
is_n2o_value:
target: *target_n2o
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *n2o_threshold_entity
mode: is
number: *ugm3_threshold_number

View File

@@ -1,164 +1,7 @@
{ {
"conditions": {
"is_co2_value": {
"condition": "mdi:molecule-co2"
},
"is_co_cleared": {
"condition": "mdi:check-circle"
},
"is_co_detected": {
"condition": "mdi:molecule-co"
},
"is_co_value": {
"condition": "mdi:molecule-co"
},
"is_gas_cleared": {
"condition": "mdi:check-circle"
},
"is_gas_detected": {
"condition": "mdi:gas-cylinder"
},
"is_n2o_value": {
"condition": "mdi:factory"
},
"is_no2_value": {
"condition": "mdi:factory"
},
"is_no_value": {
"condition": "mdi:factory"
},
"is_ozone_value": {
"condition": "mdi:weather-sunny-alert"
},
"is_pm10_value": {
"condition": "mdi:blur"
},
"is_pm1_value": {
"condition": "mdi:blur"
},
"is_pm25_value": {
"condition": "mdi:blur"
},
"is_pm4_value": {
"condition": "mdi:blur"
},
"is_smoke_cleared": {
"condition": "mdi:check-circle"
},
"is_smoke_detected": {
"condition": "mdi:smoke-detector-variant"
},
"is_so2_value": {
"condition": "mdi:factory"
},
"is_voc_ratio_value": {
"condition": "mdi:air-filter"
},
"is_voc_value": {
"condition": "mdi:air-filter"
}
},
"entity_component": { "entity_component": {
"_": { "_": {
"default": "mdi:air-filter" "default": "mdi:air-filter"
} }
},
"triggers": {
"co2_changed": {
"trigger": "mdi:molecule-co2"
},
"co2_crossed_threshold": {
"trigger": "mdi:molecule-co2"
},
"co_changed": {
"trigger": "mdi:molecule-co"
},
"co_cleared": {
"trigger": "mdi:check-circle"
},
"co_crossed_threshold": {
"trigger": "mdi:molecule-co"
},
"co_detected": {
"trigger": "mdi:molecule-co"
},
"gas_cleared": {
"trigger": "mdi:check-circle"
},
"gas_detected": {
"trigger": "mdi:gas-cylinder"
},
"n2o_changed": {
"trigger": "mdi:factory"
},
"n2o_crossed_threshold": {
"trigger": "mdi:factory"
},
"no2_changed": {
"trigger": "mdi:factory"
},
"no2_crossed_threshold": {
"trigger": "mdi:factory"
},
"no_changed": {
"trigger": "mdi:factory"
},
"no_crossed_threshold": {
"trigger": "mdi:factory"
},
"ozone_changed": {
"trigger": "mdi:weather-sunny-alert"
},
"ozone_crossed_threshold": {
"trigger": "mdi:weather-sunny-alert"
},
"pm10_changed": {
"trigger": "mdi:blur"
},
"pm10_crossed_threshold": {
"trigger": "mdi:blur"
},
"pm1_changed": {
"trigger": "mdi:blur"
},
"pm1_crossed_threshold": {
"trigger": "mdi:blur"
},
"pm25_changed": {
"trigger": "mdi:blur"
},
"pm25_crossed_threshold": {
"trigger": "mdi:blur"
},
"pm4_changed": {
"trigger": "mdi:blur"
},
"pm4_crossed_threshold": {
"trigger": "mdi:blur"
},
"smoke_cleared": {
"trigger": "mdi:check-circle"
},
"smoke_detected": {
"trigger": "mdi:smoke-detector-variant"
},
"so2_changed": {
"trigger": "mdi:factory"
},
"so2_crossed_threshold": {
"trigger": "mdi:factory"
},
"voc_changed": {
"trigger": "mdi:air-filter"
},
"voc_crossed_threshold": {
"trigger": "mdi:air-filter"
},
"voc_ratio_changed": {
"trigger": "mdi:air-filter"
},
"voc_ratio_crossed_threshold": {
"trigger": "mdi:air-filter"
}
} }
} }

View File

@@ -1,647 +0,0 @@
{
"common": {
"condition_behavior_description": "How the value should match on the targeted entities.",
"condition_behavior_name": "Behavior",
"condition_threshold_description": "What to test for and threshold values.",
"condition_threshold_name": "Threshold configuration",
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
"trigger_behavior_name": "Behavior",
"trigger_threshold_changed_description": "Which changes to trigger on and threshold values.",
"trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.",
"trigger_threshold_name": "Threshold configuration"
},
"conditions": {
"is_co2_value": {
"description": "Tests the carbon dioxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Carbon dioxide value"
},
"is_co_cleared": {
"description": "Tests if one or more carbon monoxide sensors are cleared.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Carbon monoxide cleared"
},
"is_co_detected": {
"description": "Tests if one or more carbon monoxide sensors are detecting carbon monoxide.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Carbon monoxide detected"
},
"is_co_value": {
"description": "Tests the carbon monoxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Carbon monoxide value"
},
"is_gas_cleared": {
"description": "Tests if one or more gas sensors are cleared.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Gas cleared"
},
"is_gas_detected": {
"description": "Tests if one or more gas sensors are detecting gas.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Gas detected"
},
"is_n2o_value": {
"description": "Tests the nitrous oxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Nitrous oxide value"
},
"is_no2_value": {
"description": "Tests the nitrogen dioxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Nitrogen dioxide value"
},
"is_no_value": {
"description": "Tests the nitrogen monoxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Nitrogen monoxide value"
},
"is_ozone_value": {
"description": "Tests the ozone level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Ozone value"
},
"is_pm10_value": {
"description": "Tests the PM10 level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "PM10 value"
},
"is_pm1_value": {
"description": "Tests the PM1 level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "PM1 value"
},
"is_pm25_value": {
"description": "Tests the PM2.5 level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "PM2.5 value"
},
"is_pm4_value": {
"description": "Tests the PM4 level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "PM4 value"
},
"is_smoke_cleared": {
"description": "Tests if one or more smoke sensors are cleared.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Smoke cleared"
},
"is_smoke_detected": {
"description": "Tests if one or more smoke sensors are detecting smoke.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Smoke detected"
},
"is_so2_value": {
"description": "Tests the sulphur dioxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Sulphur dioxide value"
},
"is_voc_ratio_value": {
"description": "Tests the volatile organic compounds ratio of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Volatile organic compounds ratio value"
},
"is_voc_value": {
"description": "Tests the volatile organic compounds level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
"name": "Volatile organic compounds value"
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Air Quality",
"triggers": {
"co2_changed": {
"description": "Triggers after one or more carbon dioxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Carbon dioxide level changed"
},
"co2_crossed_threshold": {
"description": "Triggers after one or more carbon dioxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Carbon dioxide level crossed threshold"
},
"co_changed": {
"description": "Triggers after one or more carbon monoxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Carbon monoxide level changed"
},
"co_cleared": {
"description": "Triggers after one or more carbon monoxide sensors stop detecting carbon monoxide.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Carbon monoxide cleared"
},
"co_crossed_threshold": {
"description": "Triggers after one or more carbon monoxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Carbon monoxide level crossed threshold"
},
"co_detected": {
"description": "Triggers after one or more carbon monoxide sensors start detecting carbon monoxide.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Carbon monoxide detected"
},
"gas_cleared": {
"description": "Triggers after one or more gas sensors stop detecting gas.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Gas cleared"
},
"gas_detected": {
"description": "Triggers after one or more gas sensors start detecting gas.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Gas detected"
},
"n2o_changed": {
"description": "Triggers after one or more nitrous oxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrous oxide level changed"
},
"n2o_crossed_threshold": {
"description": "Triggers after one or more nitrous oxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrous oxide level crossed threshold"
},
"no2_changed": {
"description": "Triggers after one or more nitrogen dioxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrogen dioxide level changed"
},
"no2_crossed_threshold": {
"description": "Triggers after one or more nitrogen dioxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrogen dioxide level crossed threshold"
},
"no_changed": {
"description": "Triggers after one or more nitrogen monoxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrogen monoxide level changed"
},
"no_crossed_threshold": {
"description": "Triggers after one or more nitrogen monoxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Nitrogen monoxide level crossed threshold"
},
"ozone_changed": {
"description": "Triggers after one or more ozone levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Ozone level changed"
},
"ozone_crossed_threshold": {
"description": "Triggers after one or more ozone levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Ozone level crossed threshold"
},
"pm10_changed": {
"description": "Triggers after one or more PM10 levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM10 level changed"
},
"pm10_crossed_threshold": {
"description": "Triggers after one or more PM10 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM10 level crossed threshold"
},
"pm1_changed": {
"description": "Triggers after one or more PM1 levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM1 level changed"
},
"pm1_crossed_threshold": {
"description": "Triggers after one or more PM1 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM1 level crossed threshold"
},
"pm25_changed": {
"description": "Triggers after one or more PM2.5 levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM2.5 level changed"
},
"pm25_crossed_threshold": {
"description": "Triggers after one or more PM2.5 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM2.5 level crossed threshold"
},
"pm4_changed": {
"description": "Triggers after one or more PM4 levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM4 level changed"
},
"pm4_crossed_threshold": {
"description": "Triggers after one or more PM4 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "PM4 level crossed threshold"
},
"smoke_cleared": {
"description": "Triggers after one or more smoke sensors stop detecting smoke.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Smoke cleared"
},
"smoke_detected": {
"description": "Triggers after one or more smoke sensors start detecting smoke.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Smoke detected"
},
"so2_changed": {
"description": "Triggers after one or more sulphur dioxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Sulphur dioxide level changed"
},
"so2_crossed_threshold": {
"description": "Triggers after one or more sulphur dioxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Sulphur dioxide level crossed threshold"
},
"voc_changed": {
"description": "Triggers after one or more volatile organic compound levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Volatile organic compounds level changed"
},
"voc_crossed_threshold": {
"description": "Triggers after one or more volatile organic compounds levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Volatile organic compounds level crossed threshold"
},
"voc_ratio_changed": {
"description": "Triggers after one or more volatile organic compound ratios change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Volatile organic compounds ratio changed"
},
"voc_ratio_crossed_threshold": {
"description": "Triggers after one or more volatile organic compounds ratios cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
"name": "Volatile organic compounds ratio crossed threshold"
}
}
}

View File

@@ -1,206 +0,0 @@
"""Provides triggers for air quality."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_changed_with_unit_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_numerical_state_crossed_threshold_with_unit_trigger,
make_entity_target_state_trigger,
)
from homeassistant.util.unit_conversion import (
CarbonMonoxideConcentrationConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
NitrogenMonoxideConcentrationConverter,
OzoneConcentrationConverter,
SulphurDioxideConcentrationConverter,
UnitlessRatioConverter,
)
def _make_detected_trigger(
device_class: BinarySensorDeviceClass,
) -> type[EntityTargetStateTriggerBase]:
"""Create a detected trigger for a binary sensor device class."""
return make_entity_target_state_trigger(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
)
def _make_cleared_trigger(
device_class: BinarySensorDeviceClass,
) -> type[EntityTargetStateTriggerBase]:
"""Create a cleared trigger for a binary sensor device class."""
return make_entity_target_state_trigger(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
)
TRIGGERS: dict[str, type[Trigger]] = {
# Binary sensor triggers (detected/cleared)
"gas_detected": _make_detected_trigger(BinarySensorDeviceClass.GAS),
"gas_cleared": _make_cleared_trigger(BinarySensorDeviceClass.GAS),
"co_detected": _make_detected_trigger(BinarySensorDeviceClass.CO),
"co_cleared": _make_cleared_trigger(BinarySensorDeviceClass.CO),
"smoke_detected": _make_detected_trigger(BinarySensorDeviceClass.SMOKE),
"smoke_cleared": _make_cleared_trigger(BinarySensorDeviceClass.SMOKE),
# Numerical sensor triggers with unit conversion
"co_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"voc_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"no_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"so2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
"so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
# Numerical sensor triggers without unit conversion (single-unit device classes)
"co2_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"co2_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"pm1_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm1_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm25_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm25_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm4_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm4_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm10_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm10_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"n2o_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"n2o_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for air quality."""
return TRIGGERS

View File

@@ -1,617 +0,0 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
# --- Unit lists for multi-unit pollutants ---
.co_units: &co_units
- "ppb"
- "ppm"
- "mg/m³"
- "μg/m³"
.ozone_units: &ozone_units
- "ppb"
- "ppm"
- "μg/m³"
.voc_units: &voc_units
- "μg/m³"
- "mg/m³"
.voc_ratio_units: &voc_ratio_units
- "ppb"
- "ppm"
.no_units: &no_units
- "ppb"
- "μg/m³"
.no2_units: &no2_units
- "ppb"
- "ppm"
- "μg/m³"
.so2_units: &so2_units
- "ppb"
- "μg/m³"
# --- Entity filter anchors ---
.co_threshold_entity: &co_threshold_entity
- domain: input_number
unit_of_measurement: *co_units
- domain: sensor
device_class: carbon_monoxide
- domain: number
device_class: carbon_monoxide
.co2_threshold_entity: &co2_threshold_entity
- domain: input_number
unit_of_measurement: "ppm"
- domain: sensor
device_class: carbon_dioxide
- domain: number
device_class: carbon_dioxide
.pm1_threshold_entity: &pm1_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm1
- domain: number
device_class: pm1
.pm25_threshold_entity: &pm25_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm25
- domain: number
device_class: pm25
.pm4_threshold_entity: &pm4_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm4
- domain: number
device_class: pm4
.pm10_threshold_entity: &pm10_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm10
- domain: number
device_class: pm10
.ozone_threshold_entity: &ozone_threshold_entity
- domain: input_number
unit_of_measurement: *ozone_units
- domain: sensor
device_class: ozone
- domain: number
device_class: ozone
.voc_threshold_entity: &voc_threshold_entity
- domain: input_number
unit_of_measurement: *voc_units
- domain: sensor
device_class: volatile_organic_compounds
- domain: number
device_class: volatile_organic_compounds
.voc_ratio_threshold_entity: &voc_ratio_threshold_entity
- domain: input_number
unit_of_measurement: *voc_ratio_units
- domain: sensor
device_class: volatile_organic_compounds_parts
- domain: number
device_class: volatile_organic_compounds_parts
.no_threshold_entity: &no_threshold_entity
- domain: input_number
unit_of_measurement: *no_units
- domain: sensor
device_class: nitrogen_monoxide
- domain: number
device_class: nitrogen_monoxide
.no2_threshold_entity: &no2_threshold_entity
- domain: input_number
unit_of_measurement: *no2_units
- domain: sensor
device_class: nitrogen_dioxide
- domain: number
device_class: nitrogen_dioxide
.n2o_threshold_entity: &n2o_threshold_entity
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: nitrous_oxide
- domain: number
device_class: nitrous_oxide
.so2_threshold_entity: &so2_threshold_entity
- domain: input_number
unit_of_measurement: *so2_units
- domain: sensor
device_class: sulphur_dioxide
- domain: number
device_class: sulphur_dioxide
# --- Number anchors for single-unit pollutants ---
.co2_threshold_number: &co2_threshold_number
mode: box
unit_of_measurement: "ppm"
.ugm3_threshold_number: &ugm3_threshold_number
mode: box
unit_of_measurement: "μg/m³"
# Binary sensor detected/cleared trigger fields
.trigger_binary_fields: &trigger_binary_fields
behavior: *trigger_behavior
# --- Binary sensor targets ---
.target_gas: &target_gas
entity:
- domain: binary_sensor
device_class: gas
.target_co_binary: &target_co_binary
entity:
- domain: binary_sensor
device_class: carbon_monoxide
.target_smoke: &target_smoke
entity:
- domain: binary_sensor
device_class: smoke
# --- Sensor targets ---
.target_co_sensor: &target_co_sensor
entity:
- domain: sensor
device_class: carbon_monoxide
.target_co2: &target_co2
entity:
- domain: sensor
device_class: carbon_dioxide
.target_pm1: &target_pm1
entity:
- domain: sensor
device_class: pm1
.target_pm25: &target_pm25
entity:
- domain: sensor
device_class: pm25
.target_pm4: &target_pm4
entity:
- domain: sensor
device_class: pm4
.target_pm10: &target_pm10
entity:
- domain: sensor
device_class: pm10
.target_ozone: &target_ozone
entity:
- domain: sensor
device_class: ozone
.target_voc: &target_voc
entity:
- domain: sensor
device_class: volatile_organic_compounds
.target_voc_ratio: &target_voc_ratio
entity:
- domain: sensor
device_class: volatile_organic_compounds_parts
.target_no: &target_no
entity:
- domain: sensor
device_class: nitrogen_monoxide
.target_no2: &target_no2
entity:
- domain: sensor
device_class: nitrogen_dioxide
.target_n2o: &target_n2o
entity:
- domain: sensor
device_class: nitrous_oxide
.target_so2: &target_so2
entity:
- domain: sensor
device_class: sulphur_dioxide
# --- Binary sensor triggers ---
gas_detected:
fields: *trigger_binary_fields
target: *target_gas
gas_cleared:
fields: *trigger_binary_fields
target: *target_gas
co_detected:
fields: *trigger_binary_fields
target: *target_co_binary
co_cleared:
fields: *trigger_binary_fields
target: *target_co_binary
smoke_detected:
fields: *trigger_binary_fields
target: *target_smoke
smoke_cleared:
fields: *trigger_binary_fields
target: *target_smoke
# --- Numerical sensor triggers ---
# CO (multi-unit)
co_changed:
target: *target_co_sensor
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *co_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *co_units
co_crossed_threshold:
target: *target_co_sensor
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *co_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *co_units
# CO2 (single-unit: ppm)
co2_changed:
target: *target_co2
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *co2_threshold_entity
mode: changed
number: *co2_threshold_number
co2_crossed_threshold:
target: *target_co2
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *co2_threshold_entity
mode: crossed
number: *co2_threshold_number
# PM1 (single-unit: μg/m³)
pm1_changed:
target: *target_pm1
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *pm1_threshold_entity
mode: changed
number: *ugm3_threshold_number
pm1_crossed_threshold:
target: *target_pm1
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm1_threshold_entity
mode: crossed
number: *ugm3_threshold_number
# PM2.5 (single-unit: μg/m³)
pm25_changed:
target: *target_pm25
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *pm25_threshold_entity
mode: changed
number: *ugm3_threshold_number
pm25_crossed_threshold:
target: *target_pm25
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm25_threshold_entity
mode: crossed
number: *ugm3_threshold_number
# PM4 (single-unit: μg/m³)
pm4_changed:
target: *target_pm4
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *pm4_threshold_entity
mode: changed
number: *ugm3_threshold_number
pm4_crossed_threshold:
target: *target_pm4
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm4_threshold_entity
mode: crossed
number: *ugm3_threshold_number
# PM10 (single-unit: μg/m³)
pm10_changed:
target: *target_pm10
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *pm10_threshold_entity
mode: changed
number: *ugm3_threshold_number
pm10_crossed_threshold:
target: *target_pm10
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *pm10_threshold_entity
mode: crossed
number: *ugm3_threshold_number
# Ozone (multi-unit)
ozone_changed:
target: *target_ozone
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *ozone_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *ozone_units
ozone_crossed_threshold:
target: *target_ozone
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *ozone_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *ozone_units
# VOC (multi-unit)
voc_changed:
target: *target_voc
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *voc_units
voc_crossed_threshold:
target: *target_voc
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *voc_units
# VOC ratio (multi-unit)
voc_ratio_changed:
target: *target_voc_ratio
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_ratio_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *voc_ratio_units
voc_ratio_crossed_threshold:
target: *target_voc_ratio
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *voc_ratio_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *voc_ratio_units
# NO (multi-unit)
no_changed:
target: *target_no
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *no_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *no_units
no_crossed_threshold:
target: *target_no
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *no_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *no_units
# NO2 (multi-unit)
no2_changed:
target: *target_no2
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *no2_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *no2_units
no2_crossed_threshold:
target: *target_no2
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *no2_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *no2_units
# N2O (single-unit: μg/m³)
n2o_changed:
target: *target_n2o
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *n2o_threshold_entity
mode: changed
number: *ugm3_threshold_number
n2o_crossed_threshold:
target: *target_n2o
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *n2o_threshold_entity
mode: crossed
number: *ugm3_threshold_number
# SO2 (multi-unit)
so2_changed:
target: *target_so2
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *so2_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *so2_units
so2_crossed_threshold:
target: *target_so2
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *so2_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *so2_units

View File

@@ -87,7 +87,7 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Successfully connected to %s", user_input[CONF_IP_ADDRESS]) _LOGGER.debug("Successfully connected to %s", user_input[CONF_IP_ADDRESS])
device_info = await airq.fetch_device_info() device_info = await airq.fetch_device_info()
await self.async_set_unique_id(device_info["id"], raise_on_progress=False) await self.async_set_unique_id(device_info["id"])
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
_LOGGER.debug("Creating an entry for %s", device_info["name"]) _LOGGER.debug("Creating an entry for %s", device_info["name"])

View File

@@ -1,36 +0,0 @@
"""Diagnostics support for air-Q."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant
from . import AirQConfigEntry
REDACT_CONFIG = {CONF_PASSWORD, CONF_UNIQUE_ID, CONF_IP_ADDRESS, "title"}
REDACT_DEVICE_INFO = {"identifiers", "name"}
REDACT_COORDINATOR_DATA = {"DeviceID"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AirQConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"config_entry": async_redact_data(entry.as_dict(), REDACT_CONFIG),
"device_info": async_redact_data(
dict(coordinator.device_info), REDACT_DEVICE_INFO
),
"coordinator_data": async_redact_data(
coordinator.data, REDACT_COORDINATOR_DATA
),
"options": {
"clip_negative": coordinator.clip_negative,
"return_average": coordinator.return_average,
},
}

View File

@@ -2,7 +2,6 @@
"config": { "config": {
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"incomplete_discovery": "The discovered air-Q device did not provide a device ID. Ensure the firmware is up to date." "incomplete_discovery": "The discovered air-Q device did not provide a device ID. Ensure the firmware is up to date."
}, },
"error": { "error": {

View File

@@ -13,9 +13,6 @@ from homeassistant.helpers import (
config_entry_oauth2_flow, config_entry_oauth2_flow,
device_registry as dr, device_registry as dr,
) )
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from . import api from . import api
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
@@ -28,17 +25,11 @@ async def async_setup_entry(
hass: HomeAssistant, entry: AladdinConnectConfigEntry hass: HomeAssistant, entry: AladdinConnectConfigEntry
) -> bool: ) -> bool:
"""Set up Aladdin Connect Genie from a config entry.""" """Set up Aladdin Connect Genie from a config entry."""
try: implementation = (
implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation(
await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry
hass, entry
)
) )
except ImplementationUnavailableError as err: )
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)

View File

@@ -37,9 +37,6 @@
"close_door_failed": { "close_door_failed": {
"message": "Failed to close the garage door" "message": "Failed to close the garage door"
}, },
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"open_door_failed": { "open_door_failed": {
"message": "Failed to open the garage door" "message": "Failed to open the garage door"
} }

View File

@@ -173,7 +173,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]" "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
} }
}, },
"name": "Arm alarm away" "name": "Arm away"
}, },
"alarm_arm_custom_bypass": { "alarm_arm_custom_bypass": {
"description": "Arms an alarm while allowing to bypass a custom area.", "description": "Arms an alarm while allowing to bypass a custom area.",
@@ -183,7 +183,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]" "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
} }
}, },
"name": "Arm alarm with custom bypass" "name": "Arm with custom bypass"
}, },
"alarm_arm_home": { "alarm_arm_home": {
"description": "Arms an alarm in the home mode.", "description": "Arms an alarm in the home mode.",
@@ -193,7 +193,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]" "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
} }
}, },
"name": "Arm alarm home" "name": "Arm home"
}, },
"alarm_arm_night": { "alarm_arm_night": {
"description": "Arms an alarm in the night mode.", "description": "Arms an alarm in the night mode.",
@@ -203,7 +203,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]" "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
} }
}, },
"name": "Arm alarm night" "name": "Arm night"
}, },
"alarm_arm_vacation": { "alarm_arm_vacation": {
"description": "Arms an alarm in the vacation mode.", "description": "Arms an alarm in the vacation mode.",
@@ -213,7 +213,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]" "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
} }
}, },
"name": "Arm alarm vacation" "name": "Arm vacation"
}, },
"alarm_disarm": { "alarm_disarm": {
"description": "Disarms an alarm.", "description": "Disarms an alarm.",
@@ -223,7 +223,7 @@
"name": "Code" "name": "Code"
} }
}, },
"name": "Disarm alarm" "name": "Disarm"
}, },
"alarm_trigger": { "alarm_trigger": {
"description": "Triggers an alarm manually.", "description": "Triggers an alarm manually.",
@@ -233,7 +233,7 @@
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]" "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]"
} }
}, },
"name": "Trigger alarm" "name": "Trigger"
} }
}, },
"title": "Alarm control panel", "title": "Alarm control panel",

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioamazondevices"], "loggers": ["aioamazondevices"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aioamazondevices==13.3.1"] "requirements": ["aioamazondevices==13.0.1"]
} }

View File

@@ -8,6 +8,6 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["androidtvremote2"], "loggers": ["androidtvremote2"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["androidtvremote2==0.3.1"], "requirements": ["androidtvremote2==0.2.3"],
"zeroconf": ["_androidtvremote2._tcp.local."] "zeroconf": ["_androidtvremote2._tcp.local."]
} }

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pyanglianwater"], "loggers": ["pyanglianwater"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pyanglianwater==3.1.2"] "requirements": ["pyanglianwater==3.1.1"]
} }

View File

@@ -45,21 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
try: try:
await client.models.list(timeout=10.0) await client.models.list(timeout=10.0)
except anthropic.AuthenticationError as err: except anthropic.AuthenticationError as err:
raise ConfigEntryAuthFailed( raise ConfigEntryAuthFailed(err) from err
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err
except anthropic.AnthropicError as err: except anthropic.AnthropicError as err:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(err) from err
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"message": err.message
if isinstance(err, anthropic.APIError)
else str(err)
},
) from err
entry.runtime_data = client entry.runtime_data = client

View File

@@ -12,7 +12,6 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads from homeassistant.util.json import json_loads
from .const import DOMAIN
from .entity import AnthropicBaseLLMEntity from .entity import AnthropicBaseLLMEntity
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -61,7 +60,7 @@ class AnthropicTaskEntity(
if not isinstance(chat_log.content[-1], conversation.AssistantContent): if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="response_not_found" "Last content in chat log is not an AssistantContent"
) )
text = chat_log.content[-1].content or "" text = chat_log.content[-1].content or ""
@@ -79,9 +78,7 @@ class AnthropicTaskEntity(
err, err,
text, text,
) )
raise HomeAssistantError( raise HomeAssistantError("Error with Claude structured response") from err
translation_domain=DOMAIN, translation_key="json_parse_error"
) from err
return ai_task.GenDataTaskResult( return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id, conversation_id=chat_log.conversation_id,

View File

@@ -401,11 +401,7 @@ def _convert_content(
messages[-1]["content"] = messages[-1]["content"][0]["text"] messages[-1]["content"] = messages[-1]["content"][0]["text"]
else: else:
# Note: We don't pass SystemContent here as it's passed to the API as the prompt # Note: We don't pass SystemContent here as it's passed to the API as the prompt
raise HomeAssistantError( raise HomeAssistantError("Unexpected content type in chat log")
translation_domain=DOMAIN,
translation_key="unexpected_chat_log_content",
translation_placeholders={"type": type(content).__name__},
)
return messages, container_id return messages, container_id
@@ -447,9 +443,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
Each message could contain multiple blocks of the same type. Each message could contain multiple blocks of the same type.
""" """
if stream is None or not hasattr(stream, "__aiter__"): if stream is None or not hasattr(stream, "__aiter__"):
raise HomeAssistantError( raise HomeAssistantError("Expected a stream of messages")
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
)
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
current_tool_args: str current_tool_args: str
@@ -611,9 +605,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
chat_log.async_trace(_create_token_stats(input_usage, usage)) chat_log.async_trace(_create_token_stats(input_usage, usage))
content_details.container = response.delta.container content_details.container = response.delta.container
if response.delta.stop_reason == "refusal": if response.delta.stop_reason == "refusal":
raise HomeAssistantError( raise HomeAssistantError("Potential policy violation detected")
translation_domain=DOMAIN, translation_key="api_refusal"
)
elif isinstance(response, RawMessageStopEvent): elif isinstance(response, RawMessageStopEvent):
if content_details: if content_details:
content_details.delete_empty() content_details.delete_empty()
@@ -672,9 +664,7 @@ class AnthropicBaseLLMEntity(Entity):
system = chat_log.content[0] system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent): if not isinstance(system, conversation.SystemContent):
raise HomeAssistantError( raise HomeAssistantError("First message must be a system message")
translation_domain=DOMAIN, translation_key="system_message_not_found"
)
# System prompt with caching enabled # System prompt with caching enabled
system_prompt: list[TextBlockParam] = [ system_prompt: list[TextBlockParam] = [
@@ -764,7 +754,7 @@ class AnthropicBaseLLMEntity(Entity):
last_message = messages[-1] last_message = messages[-1]
if last_message["role"] != "user": if last_message["role"] != "user":
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="user_message_not_found" "Last message must be a user message to add attachments"
) )
if isinstance(last_message["content"], str): if isinstance(last_message["content"], str):
last_message["content"] = [ last_message["content"] = [
@@ -869,19 +859,11 @@ class AnthropicBaseLLMEntity(Entity):
except anthropic.AuthenticationError as err: except anthropic.AuthenticationError as err:
self.entry.async_start_reauth(self.hass) self.entry.async_start_reauth(self.hass)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, "Authentication error with Anthropic API, reauthentication required"
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err ) from err
except anthropic.AnthropicError as err: except anthropic.AnthropicError as err:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, f"Sorry, I had a problem talking to Anthropic: {err}"
translation_key="api_error",
translation_placeholders={
"message": err.message
if isinstance(err, anthropic.APIError)
else str(err)
},
) from err ) from err
if not chat_log.unresponded_tool_results: if not chat_log.unresponded_tool_results:
@@ -901,23 +883,15 @@ async def async_prepare_files_for_prompt(
for file_path, mime_type in files: for file_path, mime_type in files:
if not file_path.exists(): if not file_path.exists():
raise HomeAssistantError( raise HomeAssistantError(f"`{file_path}` does not exist")
translation_domain=DOMAIN,
translation_key="wrong_file_path",
translation_placeholders={"file_path": file_path.as_posix()},
)
if mime_type is None: if mime_type is None:
mime_type = guess_file_type(file_path)[0] mime_type = guess_file_type(file_path)[0]
if not mime_type or not mime_type.startswith(("image/", "application/pdf")): if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, "Only images and PDF are supported by the Anthropic API,"
translation_key="wrong_file_type", f"`{file_path}` is not an image file or PDF"
translation_placeholders={
"file_path": file_path.as_posix(),
"mime_type": mime_type or "unknown",
},
) )
if mime_type == "image/jpg": if mime_type == "image/jpg":
mime_type = "image/jpeg" mime_type = "image/jpeg"

View File

@@ -59,7 +59,10 @@ rules:
status: exempt status: exempt
comment: | comment: |
No data updates. No data updates.
docs-examples: done docs-examples:
status: todo
comment: |
To give examples of how people use the integration
docs-known-limitations: done docs-known-limitations: done
docs-supported-devices: docs-supported-devices:
status: todo status: todo
@@ -85,7 +88,7 @@ rules:
comment: | comment: |
No entities disabled by default. No entities disabled by default.
entity-translations: todo entity-translations: todo
exception-translations: done exception-translations: todo
icon-translations: done icon-translations: done
reconfiguration-flow: done reconfiguration-flow: done
repair-issues: done repair-issues: done

View File

@@ -161,9 +161,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
is None is None
or (subentry := entry.subentries.get(self._current_subentry_id)) is None or (subentry := entry.subentries.get(self._current_subentry_id)) is None
): ):
raise HomeAssistantError( raise HomeAssistantError("Subentry not found")
translation_domain=DOMAIN, translation_key="subentry_not_found"
)
updated_data = { updated_data = {
**subentry.data, **subentry.data,
@@ -192,6 +190,4 @@ async def async_create_fix_flow(
"""Create flow.""" """Create flow."""
if issue_id == "model_deprecated": if issue_id == "model_deprecated":
return ModelDeprecatedRepairFlow() return ModelDeprecatedRepairFlow()
raise HomeAssistantError( raise HomeAssistantError("Unknown issue ID")
translation_domain=DOMAIN, translation_key="unknown_issue_id"
)

View File

@@ -149,47 +149,6 @@
} }
} }
}, },
"exceptions": {
"api_authentication_error": {
"message": "Authentication error with Anthropic API: {message}. Reauthentication required."
},
"api_error": {
"message": "Anthropic API error: {message}."
},
"api_refusal": {
"message": "Potential policy violation detected."
},
"json_parse_error": {
"message": "Error with Claude structured response."
},
"response_not_found": {
"message": "Last content in chat log is not an AssistantContent."
},
"subentry_not_found": {
"message": "Subentry not found."
},
"system_message_not_found": {
"message": "First message must be a system message."
},
"unexpected_chat_log_content": {
"message": "Unexpected content type in chat log: {type}."
},
"unexpected_stream_object": {
"message": "Expected a stream of messages."
},
"unknown_issue_id": {
"message": "Unknown issue ID."
},
"user_message_not_found": {
"message": "Last message must be a user message to add attachments."
},
"wrong_file_path": {
"message": "`{file_path}` does not exist."
},
"wrong_file_type": {
"message": "Only images and PDF are supported by the Anthropic API, `{file_path}` ({mime_type}) is not an image file or PDF."
}
},
"issues": { "issues": {
"model_deprecated": { "model_deprecated": {
"fix_flow": { "fix_flow": {

View File

@@ -2,8 +2,8 @@
import asyncio import asyncio
from asyncio import timeout from asyncio import timeout
from contextlib import AsyncExitStack
import logging import logging
from typing import Any
from arcam.fmj import ConnectionFailed from arcam.fmj import ConnectionFailed
from arcam.fmj.client import Client from arcam.fmj.client import Client
@@ -54,31 +54,36 @@ async def _run_client(
client = runtime_data.client client = runtime_data.client
coordinators = runtime_data.coordinators coordinators = runtime_data.coordinators
def _listen(_: Any) -> None:
for coordinator in coordinators.values():
coordinator.async_notify_data_updated()
while True: while True:
try: try:
async with AsyncExitStack() as stack: async with timeout(interval):
async with timeout(interval): await client.start()
await client.start()
stack.push_async_callback(client.stop)
_LOGGER.debug("Client connected %s", client.host) _LOGGER.debug("Client connected %s", client.host)
try: try:
for coordinator in coordinators.values():
await coordinator.state.start()
with client.listen(_listen):
for coordinator in coordinators.values(): for coordinator in coordinators.values():
await stack.enter_async_context( coordinator.async_notify_connected()
coordinator.async_monitor_client()
)
await client.process() await client.process()
finally: finally:
_LOGGER.debug("Client disconnected %s", client.host) await client.stop()
_LOGGER.debug("Client disconnected %s", client.host)
for coordinator in coordinators.values():
coordinator.async_notify_disconnected()
except ConnectionFailed: except ConnectionFailed:
pass await asyncio.sleep(interval)
except TimeoutError: except TimeoutError:
continue continue
except Exception: except Exception:
_LOGGER.exception("Unexpected exception, aborting arcam client") _LOGGER.exception("Unexpected exception, aborting arcam client")
return return
await asyncio.sleep(interval)

View File

@@ -2,13 +2,11 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from arcam.fmj import ConnectionFailed from arcam.fmj import ConnectionFailed
from arcam.fmj.client import AmxDuetResponse, Client, ResponsePacket from arcam.fmj.client import Client
from arcam.fmj.state import State from arcam.fmj.state import State
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -53,7 +51,7 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
) )
self.client = client self.client = client
self.state = State(client, zone) self.state = State(client, zone)
self.update_in_progress = False self.last_update_success = False
name = config_entry.title name = config_entry.title
unique_id = config_entry.unique_id or config_entry.entry_id unique_id = config_entry.unique_id or config_entry.entry_id
@@ -76,34 +74,24 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None: async def _async_update_data(self) -> None:
"""Fetch data for manual refresh.""" """Fetch data for manual refresh."""
try: try:
self.update_in_progress = True
await self.state.update() await self.state.update()
except ConnectionFailed as err: except ConnectionFailed as err:
raise UpdateFailed( raise UpdateFailed(
f"Connection failed during update for zone {self.state.zn}" f"Connection failed during update for zone {self.state.zn}"
) from err ) from err
finally:
self.update_in_progress = False
@callback @callback
def _async_notify_packet(self, packet: ResponsePacket | AmxDuetResponse) -> None: def async_notify_data_updated(self) -> None:
"""Packet callback to detect changes to state.""" """Notify that new data has been received from the device."""
if ( self.async_set_updated_data(None)
not isinstance(packet, ResponsePacket)
or packet.zn != self.state.zn
or self.update_in_progress
):
return
@callback
def async_notify_connected(self) -> None:
"""Handle client connected."""
self.hass.async_create_task(self.async_refresh())
@callback
def async_notify_disconnected(self) -> None:
"""Handle client disconnected."""
self.last_update_success = False
self.async_update_listeners() self.async_update_listeners()
@asynccontextmanager
async def async_monitor_client(self) -> AsyncGenerator[None]:
"""Monitor a client and state for changes while connected."""
async with self.state:
self.hass.async_create_task(self.async_refresh())
try:
with self.client.listen(self._async_notify_packet):
yield
finally:
self.hass.async_create_task(self.async_refresh())

View File

@@ -26,8 +26,3 @@ class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
if description is not None: if description is not None:
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
self.entity_description = description self.entity_description = description
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.client.connected

View File

@@ -137,4 +137,5 @@ async def async_pipeline_from_audio_stream(
audio_settings=audio_settings or AudioSettings(), audio_settings=audio_settings or AudioSettings(),
), ),
) )
await pipeline_input.execute(validate=True) await pipeline_input.validate()
await pipeline_input.execute()

View File

@@ -1,14 +1,7 @@
"""Assist pipeline errors.""" """Assist pipeline errors."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
if TYPE_CHECKING:
from .pipeline import PipelineStage
class PipelineError(HomeAssistantError): class PipelineError(HomeAssistantError):
"""Base class for pipeline errors.""" """Base class for pipeline errors."""
@@ -62,25 +55,3 @@ class IntentRecognitionError(PipelineError):
class TextToSpeechError(PipelineError): class TextToSpeechError(PipelineError):
"""Error in text-to-speech portion of pipeline.""" """Error in text-to-speech portion of pipeline."""
class PipelineRunValidationError(PipelineError):
"""Error when a pipeline run is not valid."""
def __init__(self, message: str) -> None:
"""Set error message."""
super().__init__("validation-error", message)
class InvalidPipelineStagesError(PipelineRunValidationError):
"""Error when given an invalid combination of start/end stages."""
def __init__(
self,
start_stage: PipelineStage,
end_stage: PipelineStage,
) -> None:
"""Set error message."""
super().__init__(
f"Invalid stage combination: start={start_stage}, end={end_stage}"
)

View File

@@ -73,10 +73,8 @@ from .const import (
from .error import ( from .error import (
DuplicateWakeUpDetectedError, DuplicateWakeUpDetectedError,
IntentRecognitionError, IntentRecognitionError,
InvalidPipelineStagesError,
PipelineError, PipelineError,
PipelineNotFound, PipelineNotFound,
PipelineRunValidationError,
SpeechToTextError, SpeechToTextError,
TextToSpeechError, TextToSpeechError,
WakeWordDetectionAborted, WakeWordDetectionAborted,
@@ -494,6 +492,24 @@ PIPELINE_STAGE_ORDER = [
] ]
class PipelineRunValidationError(Exception):
"""Error when a pipeline run is not valid."""
class InvalidPipelineStagesError(PipelineRunValidationError):
"""Error when given an invalid combination of start/end stages."""
def __init__(
self,
start_stage: PipelineStage,
end_stage: PipelineStage,
) -> None:
"""Set error message."""
super().__init__(
f"Invalid stage combination: start={start_stage}, end={end_stage}"
)
@dataclass(frozen=True) @dataclass(frozen=True)
class WakeWordSettings: class WakeWordSettings:
"""Settings for wake word detection.""" """Settings for wake word detection."""
@@ -646,8 +662,7 @@ class PipelineRun:
"""Emit run start event.""" """Emit run start event."""
self._device_id = device_id self._device_id = device_id
self._satellite_id = satellite_id self._satellite_id = satellite_id
if self.start_stage in (PipelineStage.WAKE_WORD, PipelineStage.STT): self._start_debug_recording_thread()
self._start_debug_recording_thread()
data: dict[str, Any] = { data: dict[str, Any] = {
"pipeline": self.pipeline.id, "pipeline": self.pipeline.id,
@@ -1489,7 +1504,9 @@ class PipelineRun:
def _start_debug_recording_thread(self) -> None: def _start_debug_recording_thread(self) -> None:
"""Start thread to record wake/stt audio if debug_recording_dir is set.""" """Start thread to record wake/stt audio if debug_recording_dir is set."""
assert self.debug_recording_thread is None if self.debug_recording_thread is not None:
# Already started
return
# Directory to save audio for each pipeline run. # Directory to save audio for each pipeline run.
# Configured in YAML for assist_pipeline. # Configured in YAML for assist_pipeline.
@@ -1664,39 +1681,26 @@ class PipelineInput:
satellite_id: str | None = None satellite_id: str | None = None
"""Identifier of the satellite that is processing the input/output of the pipeline.""" """Identifier of the satellite that is processing the input/output of the pipeline."""
async def execute(self, validate: bool = False) -> None: async def execute(self) -> None:
"""Run pipeline.""" """Run pipeline."""
validation_error: PipelineError | None = None
if validate:
try:
await self.validate()
except PipelineError as err:
validation_error = err
self.run.start( self.run.start(
conversation_id=self.session.conversation_id, conversation_id=self.session.conversation_id,
device_id=self.device_id, device_id=self.device_id,
satellite_id=self.satellite_id, satellite_id=self.satellite_id,
) )
current_stage: PipelineStage | None = self.run.start_stage current_stage: PipelineStage | None = self.run.start_stage
stt_audio_buffer: list[EnhancedAudioChunk] = []
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
if self.stt_stream is not None:
if self.run.audio_settings.needs_processor:
# VAD/noise suppression/auto gain/volume
stt_processed_stream = self.run.process_enhance_audio(self.stt_stream)
else:
# Volume multiplier only
stt_processed_stream = self.run.process_volume_only(self.stt_stream)
try: try:
if validation_error is not None:
raise validation_error
stt_audio_buffer: list[EnhancedAudioChunk] = []
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
if self.stt_stream is not None:
if self.run.audio_settings.needs_processor:
# VAD/noise suppression/auto gain/volume
stt_processed_stream = self.run.process_enhance_audio(
self.stt_stream
)
else:
# Volume multiplier only
stt_processed_stream = self.run.process_volume_only(self.stt_stream)
if current_stage == PipelineStage.WAKE_WORD: if current_stage == PipelineStage.WAKE_WORD:
# wake-word-detection # wake-word-detection
assert stt_processed_stream is not None assert stt_processed_stream is not None

View File

@@ -626,7 +626,7 @@ def websocket_delete_all_refresh_tokens(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None: ) -> None:
"""Handle delete all refresh tokens request.""" """Handle delete all refresh tokens request."""
current_refresh_token: RefreshToken | None = None current_refresh_token: RefreshToken
remove_failed = False remove_failed = False
token_type = msg.get("token_type") token_type = msg.get("token_type")
delete_current_token = msg.get("delete_current_token") delete_current_token = msg.get("delete_current_token")
@@ -654,7 +654,7 @@ def websocket_delete_all_refresh_tokens(
else: else:
connection.send_result(msg["id"], {}) connection.send_result(msg["id"], {})
async def _delete_current_token_soon(current_refresh_token: RefreshToken) -> None: async def _delete_current_token_soon() -> None:
"""Delete the current token after a delay. """Delete the current token after a delay.
We do not want to delete the current token immediately as it will We do not want to delete the current token immediately as it will
@@ -675,15 +675,13 @@ def websocket_delete_all_refresh_tokens(
# the token right away. # the token right away.
hass.auth.async_remove_refresh_token(current_refresh_token) hass.auth.async_remove_refresh_token(current_refresh_token)
if ( if delete_current_token and (
delete_current_token not limit_token_types or current_refresh_token.token_type == token_type
and current_refresh_token
and (not limit_token_types or current_refresh_token.token_type == token_type)
): ):
# Deleting the token will close the connection so we need # Deleting the token will close the connection so we need
# to do it with a delay in a tracked task to ensure it still # to do it with a delay in a tracked task to ensure it still
# happens if Home Assistant is shutting down. # happens if Home Assistant is shutting down.
hass.async_create_task(_delete_current_token_soon(current_refresh_token)) hass.async_create_task(_delete_current_token_soon())
@websocket_api.websocket_command( @websocket_api.websocket_command(

View File

@@ -115,7 +115,6 @@ def async_setup(
) -> None: ) -> None:
"""Component to allow users to login.""" """Component to allow users to login."""
hass.http.register_view(WellKnownOAuthInfoView) hass.http.register_view(WellKnownOAuthInfoView)
hass.http.register_view(WellKnownProtectedResourceView)
hass.http.register_view(AuthProvidersView) hass.http.register_view(AuthProvidersView)
hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow, store_result)) hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow, store_result))
hass.http.register_view(LoginFlowResourceView(hass.auth.login_flow, store_result)) hass.http.register_view(LoginFlowResourceView(hass.auth.login_flow, store_result))
@@ -142,13 +141,6 @@ class WellKnownOAuthInfoView(HomeAssistantView):
"authorization_endpoint": f"{url_prefix}/auth/authorize", "authorization_endpoint": f"{url_prefix}/auth/authorize",
"token_endpoint": f"{url_prefix}/auth/token", "token_endpoint": f"{url_prefix}/auth/token",
"revocation_endpoint": f"{url_prefix}/auth/revoke", "revocation_endpoint": f"{url_prefix}/auth/revoke",
# Home Assistant already accepts URL-based client_ids via
# IndieAuth without prior registration, which is compatible with
# draft-ietf-oauth-client-id-metadata-document. This flag
# advertises that support to encourage clients to use it. The
# metadata document is not actually fetched as IndieAuth doesn't
# require it.
"client_id_metadata_document_supported": True,
"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"
@@ -162,32 +154,6 @@ class WellKnownOAuthInfoView(HomeAssistantView):
return self.json(metadata) return self.json(metadata)
class WellKnownProtectedResourceView(HomeAssistantView):
"""View to host the OAuth2 Protected Resource Metadata per RFC9728."""
requires_auth = False
url = "/.well-known/oauth-protected-resource"
name = "well-known/oauth-protected-resource"
async def get(self, request: web.Request) -> web.Response:
"""Return the protected resource metadata."""
hass = request.app[KEY_HASS]
try:
url_prefix = get_url(hass, require_current_request=True)
except NoURLAvailableError:
return self.json_message("No URL available", HTTPStatus.NOT_FOUND)
return self.json(
{
"resource": url_prefix,
"authorization_servers": [url_prefix],
"resource_documentation": (
"https://developers.home-assistant.io/docs/auth_api"
),
}
)
class AuthProvidersView(HomeAssistantView): class AuthProvidersView(HomeAssistantView):
"""View to get available auth providers.""" """View to get available auth providers."""

View File

@@ -118,11 +118,8 @@ SERVICE_TRIGGER = "trigger"
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions" NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = { _EXPERIMENTAL_CONDITION_PLATFORMS = {
"air_quality",
"alarm_control_panel", "alarm_control_panel",
"assist_satellite", "assist_satellite",
"battery",
"calendar",
"climate", "climate",
"cover", "cover",
"device_tracker", "device_tracker",
@@ -131,69 +128,50 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"garage_door", "garage_door",
"gate", "gate",
"humidifier", "humidifier",
"humidity",
"illuminance",
"lawn_mower", "lawn_mower",
"light", "light",
"lock", "lock",
"media_player", "media_player",
"moisture",
"motion", "motion",
"occupancy", "occupancy",
"person", "person",
"power",
"schedule", "schedule",
"select",
"siren", "siren",
"switch", "switch",
"temperature",
"text",
"timer",
"vacuum", "vacuum",
"valve",
"water_heater",
"window", "window",
} }
_EXPERIMENTAL_TRIGGER_PLATFORMS = { _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"air_quality",
"alarm_control_panel", "alarm_control_panel",
"assist_satellite", "assist_satellite",
"battery",
"button", "button",
"climate", "climate",
"counter",
"cover", "cover",
"device_tracker", "device_tracker",
"door", "door",
"event",
"fan", "fan",
"garage_door", "garage_door",
"gate", "gate",
"humidifier", "humidifier",
"humidity", "humidity",
"illuminance", "input_boolean",
"lawn_mower", "lawn_mower",
"light", "light",
"lock", "lock",
"media_player", "media_player",
"moisture",
"motion", "motion",
"occupancy", "occupancy",
"person", "person",
"power",
"remote", "remote",
"scene", "scene",
"schedule", "schedule",
"select", "select",
"siren", "siren",
"switch", "switch",
"temperature",
"text", "text",
"todo",
"update", "update",
"vacuum", "vacuum",
"water_heater",
"window", "window",
} }

View File

@@ -78,11 +78,11 @@
"services": { "services": {
"reload": { "reload": {
"description": "Reloads the automation configuration.", "description": "Reloads the automation configuration.",
"name": "Reload automations" "name": "[%key:common::action::reload%]"
}, },
"toggle": { "toggle": {
"description": "Toggles (enable / disable) an automation.", "description": "Toggles (enable / disable) an automation.",
"name": "Toggle automation" "name": "[%key:common::action::toggle%]"
}, },
"trigger": { "trigger": {
"description": "Triggers the actions of an automation.", "description": "Triggers the actions of an automation.",
@@ -92,7 +92,7 @@
"name": "Skip conditions" "name": "Skip conditions"
} }
}, },
"name": "Trigger automation" "name": "Trigger"
}, },
"turn_off": { "turn_off": {
"description": "Disables an automation.", "description": "Disables an automation.",
@@ -102,11 +102,11 @@
"name": "Stop actions" "name": "Stop actions"
} }
}, },
"name": "Turn off automation" "name": "[%key:common::action::turn_off%]"
}, },
"turn_on": { "turn_on": {
"description": "Enables an automation.", "description": "Enables an automation.",
"name": "Turn on automation" "name": "[%key:common::action::turn_on%]"
} }
}, },
"title": "Automation" "title": "Automation"

View File

@@ -147,7 +147,7 @@ class S3BackupAgent(BackupAgent):
if backup.size < MULTIPART_MIN_PART_SIZE_BYTES: if backup.size < MULTIPART_MIN_PART_SIZE_BYTES:
await self._upload_simple(tar_filename, open_stream) await self._upload_simple(tar_filename, open_stream)
else: else:
await self._upload_multipart(tar_filename, open_stream, on_progress) await self._upload_multipart(tar_filename, open_stream)
# Upload the metadata file # Upload the metadata file
metadata_content = json.dumps(backup.as_dict()) metadata_content = json.dumps(backup.as_dict())
@@ -188,13 +188,11 @@ class S3BackupAgent(BackupAgent):
self, self,
tar_filename: str, tar_filename: str,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
on_progress: OnProgressCallback, ):
) -> None:
"""Upload a large file using multipart upload. """Upload a large file using multipart upload.
:param tar_filename: The target filename for the backup. :param tar_filename: The target filename for the backup.
:param open_stream: A function returning an async iterator that yields bytes. :param open_stream: A function returning an async iterator that yields bytes.
:param on_progress: A callback to report the number of uploaded bytes.
""" """
_LOGGER.debug("Starting multipart upload for %s", tar_filename) _LOGGER.debug("Starting multipart upload for %s", tar_filename)
multipart_upload = await self._client.create_multipart_upload( multipart_upload = await self._client.create_multipart_upload(
@@ -207,7 +205,6 @@ class S3BackupAgent(BackupAgent):
part_number = 1 part_number = 1
buffer = bytearray() # bytes buffer to store the data buffer = bytearray() # bytes buffer to store the data
offset = 0 # start index of unread data inside buffer offset = 0 # start index of unread data inside buffer
bytes_uploaded = 0
stream = await open_stream() stream = await open_stream()
async for chunk in stream: async for chunk in stream:
@@ -236,8 +233,6 @@ class S3BackupAgent(BackupAgent):
Body=part_data.tobytes(), Body=part_data.tobytes(),
) )
parts.append({"PartNumber": part_number, "ETag": part["ETag"]}) parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(part_data)
on_progress(bytes_uploaded=bytes_uploaded)
part_number += 1 part_number += 1
finally: finally:
view.release() view.release()
@@ -266,8 +261,6 @@ class S3BackupAgent(BackupAgent):
Body=remaining_data.tobytes(), Body=remaining_data.tobytes(),
) )
parts.append({"PartNumber": part_number, "ETag": part["ETag"]}) parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(remaining_data)
on_progress(bytes_uploaded=bytes_uploaded)
await cast(Any, self._client).complete_multipart_upload( await cast(Any, self._client).complete_multipart_upload(
Bucket=self._bucket, Bucket=self._bucket,

View File

@@ -34,4 +34,4 @@ EXCLUDE_DATABASE_FROM_BACKUP = [
"home-assistant_v2.db-wal", "home-assistant_v2.db-wal",
] ]
SECURETAR_CREATE_VERSION = 3 SECURETAR_CREATE_VERSION = 2

View File

@@ -1,17 +0,0 @@
"""Integration for battery triggers and conditions."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "battery"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -1,46 +0,0 @@
"""Provides conditions for batteries."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
make_entity_numerical_condition,
make_entity_state_condition,
)
BATTERY_DOMAIN_SPECS = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.BATTERY)
}
BATTERY_CHARGING_DOMAIN_SPECS = {
BINARY_SENSOR_DOMAIN: DomainSpec(
device_class=BinarySensorDeviceClass.BATTERY_CHARGING
)
}
BATTERY_PERCENTAGE_DOMAIN_SPECS = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY),
}
CONDITIONS: dict[str, type[Condition]] = {
"is_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_ON),
"is_not_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_OFF),
"is_charging": make_entity_state_condition(BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON),
"is_not_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
),
"is_level": make_entity_numerical_condition(
BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for batteries."""
return CONDITIONS

View File

@@ -1,64 +0,0 @@
.condition_common: &condition_common
target: &target_battery_binary_sensor
entity:
- domain: binary_sensor
device_class: battery
fields:
behavior: &condition_behavior
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
.battery_threshold_entity: &battery_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: sensor
device_class: battery
- domain: number
device_class: battery
.battery_threshold_number: &battery_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
is_low: *condition_common
is_not_low: *condition_common
is_charging:
target:
entity:
- domain: binary_sensor
device_class: battery_charging
fields:
behavior: *condition_behavior
is_not_charging:
target:
entity:
- domain: binary_sensor
device_class: battery_charging
fields:
behavior: *condition_behavior
is_level:
target:
entity:
- domain: sensor
device_class: battery
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *battery_threshold_entity
mode: is
number: *battery_threshold_number

View File

@@ -1,39 +0,0 @@
{
"conditions": {
"is_charging": {
"condition": "mdi:battery-charging"
},
"is_level": {
"condition": "mdi:battery-unknown"
},
"is_low": {
"condition": "mdi:battery-alert"
},
"is_not_charging": {
"condition": "mdi:battery"
},
"is_not_low": {
"condition": "mdi:battery"
}
},
"triggers": {
"level_changed": {
"trigger": "mdi:battery-unknown"
},
"level_crossed_threshold": {
"trigger": "mdi:battery-alert"
},
"low": {
"trigger": "mdi:battery-alert"
},
"not_low": {
"trigger": "mdi:battery"
},
"started_charging": {
"trigger": "mdi:battery-charging"
},
"stopped_charging": {
"trigger": "mdi:battery"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"domain": "battery",
"name": "Battery",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/battery",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -1,151 +0,0 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted batteries.",
"condition_behavior_name": "Behavior",
"condition_threshold_description": "What to test for and threshold values.",
"condition_threshold_name": "Threshold configuration",
"trigger_behavior_description": "The behavior of the targeted batteries to trigger on.",
"trigger_behavior_name": "Behavior",
"trigger_threshold_changed_description": "Which changes to trigger on and threshold values.",
"trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.",
"trigger_threshold_name": "Threshold configuration"
},
"conditions": {
"is_charging": {
"description": "Tests if one or more batteries are charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is charging"
},
"is_level": {
"description": "Tests the battery level of one or more batteries.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::battery::common::condition_threshold_description%]",
"name": "[%key:component::battery::common::condition_threshold_name%]"
}
},
"name": "Battery level"
},
"is_low": {
"description": "Tests if one or more batteries are low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is low"
},
"is_not_charging": {
"description": "Tests if one or more batteries are not charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is not charging"
},
"is_not_low": {
"description": "Tests if one or more batteries are not low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is not low"
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Battery",
"triggers": {
"level_changed": {
"description": "Triggers after the battery level of one or more batteries changes.",
"fields": {
"threshold": {
"description": "[%key:component::battery::common::trigger_threshold_changed_description%]",
"name": "[%key:component::battery::common::trigger_threshold_name%]"
}
},
"name": "Battery level changed"
},
"level_crossed_threshold": {
"description": "Triggers after the battery level of one or more batteries crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::battery::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::battery::common::trigger_threshold_name%]"
}
},
"name": "Battery level crossed threshold"
},
"low": {
"description": "Triggers after one or more batteries become low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery low"
},
"not_low": {
"description": "Triggers after one or more batteries are no longer low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery not low"
},
"started_charging": {
"description": "Triggers after one or more batteries start charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery started charging"
},
"stopped_charging": {
"description": "Triggers after one or more batteries stop charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery stopped charging"
}
}
}

View File

@@ -1,54 +0,0 @@
"""Provides triggers for batteries."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
)
BATTERY_LOW_DOMAIN_SPECS: dict[str, DomainSpec] = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.BATTERY),
}
BATTERY_CHARGING_DOMAIN_SPECS: dict[str, DomainSpec] = {
BINARY_SENSOR_DOMAIN: DomainSpec(
device_class=BinarySensorDeviceClass.BATTERY_CHARGING
),
}
BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY),
}
TRIGGERS: dict[str, type[Trigger]] = {
"low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON),
"not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF),
"started_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON
),
"stopped_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
),
"level_changed": make_entity_numerical_state_changed_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
),
"level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for batteries."""
return TRIGGERS

View File

@@ -1,83 +0,0 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
.battery_threshold_entity: &battery_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
device_class: battery
- domain: sensor
device_class: battery
.battery_threshold_number: &battery_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
.trigger_target_battery: &trigger_target_battery
entity:
- domain: binary_sensor
device_class: battery
.trigger_target_charging: &trigger_target_charging
entity:
- domain: binary_sensor
device_class: battery_charging
.trigger_target_percentage: &trigger_target_percentage
entity:
- domain: sensor
device_class: battery
low:
fields:
behavior: *trigger_behavior
target: *trigger_target_battery
not_low:
fields:
behavior: *trigger_behavior
target: *trigger_target_battery
started_charging:
fields:
behavior: *trigger_behavior
target: *trigger_target_charging
stopped_charging:
fields:
behavior: *trigger_behavior
target: *trigger_target_charging
level_changed:
target: *trigger_target_percentage
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *battery_threshold_entity
mode: changed
number: *battery_threshold_number
level_crossed_threshold:
target: *trigger_target_percentage
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *battery_threshold_entity
mode: crossed
number: *battery_threshold_number

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/bluesound", "documentation": "https://www.home-assistant.io/integrations/bluesound",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["pyblu==2.0.6"], "requirements": ["pyblu==2.0.5"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_musc._tcp.local." "type": "_musc._tcp.local."

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.3", "bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4", "bluetooth-data-tools==1.28.4",
"dbus-fast==3.1.2", "dbus-fast==3.1.2",
"habluetooth==5.11.1" "habluetooth==5.10.2"
] ]
} }

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["bsblan"], "loggers": ["bsblan"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["python-bsblan==5.1.3"], "requirements": ["python-bsblan==5.1.2"],
"zeroconf": [ "zeroconf": [
{ {
"name": "bsb-lan*", "name": "bsb-lan*",

View File

@@ -23,8 +23,8 @@
}, },
"services": { "services": {
"press": { "press": {
"description": "Presses a button.", "description": "Presses a button entity.",
"name": "Press button" "name": "Press"
} }
}, },
"title": "Button", "title": "Button",

View File

@@ -578,13 +578,13 @@ class CalendarEntity(Entity):
return STATE_OFF return STATE_OFF
@callback @callback
def _async_write_ha_state(self) -> None: def async_write_ha_state(self) -> None:
"""Write the state to the state machine. """Write the state to the state machine.
This sets up listeners to handle state transitions for start or end of This sets up listeners to handle state transitions for start or end of
the current or upcoming event. the current or upcoming event.
""" """
super()._async_write_ha_state() super().async_write_ha_state()
if self._alarm_unsubs is None: if self._alarm_unsubs is None:
self._alarm_unsubs = [] self._alarm_unsubs = []
_LOGGER.debug( _LOGGER.debug(

View File

@@ -1,16 +0,0 @@
"""Provides conditions for calendars."""
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_event_active": make_entity_state_condition(DOMAIN, STATE_ON),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the calendar conditions."""
return CONDITIONS

View File

@@ -1,14 +0,0 @@
is_event_active:
target:
entity:
- domain: calendar
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any

View File

@@ -1,9 +1,4 @@
{ {
"conditions": {
"is_event_active": {
"condition": "mdi:calendar-check"
}
},
"entity_component": { "entity_component": {
"_": { "_": {
"default": "mdi:calendar", "default": "mdi:calendar",

View File

@@ -1,20 +1,4 @@
{ {
"common": {
"condition_behavior_description": "How the state should match on the targeted calendars.",
"condition_behavior_name": "Behavior"
},
"conditions": {
"is_event_active": {
"description": "Tests if one or more calendars have an active event.",
"fields": {
"behavior": {
"description": "[%key:component::calendar::common::condition_behavior_description%]",
"name": "[%key:component::calendar::common::condition_behavior_name%]"
}
},
"name": "Calendar event is active"
}
},
"entity_component": { "entity_component": {
"_": { "_": {
"name": "[%key:component::calendar::title%]", "name": "[%key:component::calendar::title%]",
@@ -62,12 +46,6 @@
} }
}, },
"selector": { "selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_offset_type": { "trigger_offset_type": {
"options": { "options": {
"after": "After", "after": "After",
@@ -112,7 +90,7 @@
"name": "Summary" "name": "Summary"
} }
}, },
"name": "Create calendar event" "name": "Create event"
}, },
"get_events": { "get_events": {
"description": "Retrieves events on a calendar within a time range.", "description": "Retrieves events on a calendar within a time range.",
@@ -130,7 +108,7 @@
"name": "Start time" "name": "Start time"
} }
}, },
"name": "Get calendar events" "name": "Get events"
} }
}, },
"title": "Calendar", "title": "Calendar",

View File

@@ -432,7 +432,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
) )
# Entity Properties # Entity Properties
entity_description: CameraEntityDescription
_attr_brand: str | None = None _attr_brand: str | None = None
_attr_frame_interval: float = MIN_STREAM_INTERVAL _attr_frame_interval: float = MIN_STREAM_INTERVAL
_attr_is_on: bool = True _attr_is_on: bool = True
@@ -760,12 +759,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return CameraCapabilities(frontend_stream_types) return CameraCapabilities(frontend_stream_types)
@callback @callback
def _async_write_ha_state(self) -> None: def async_write_ha_state(self) -> None:
"""Write the state to the state machine. """Write the state to the state machine.
Schedules async_refresh_providers if support of streams have changed. Schedules async_refresh_providers if support of streams have changed.
""" """
super()._async_write_ha_state() super().async_write_ha_state()
if self.__supports_stream != ( if self.__supports_stream != (
supports_stream := self.supported_features & CameraEntityFeature.STREAM supports_stream := self.supported_features & CameraEntityFeature.STREAM
): ):

View File

@@ -51,11 +51,11 @@
"services": { "services": {
"disable_motion_detection": { "disable_motion_detection": {
"description": "Disables the motion detection of a camera.", "description": "Disables the motion detection of a camera.",
"name": "Disable camera motion detection" "name": "Disable motion detection"
}, },
"enable_motion_detection": { "enable_motion_detection": {
"description": "Enables the motion detection of a camera.", "description": "Enables the motion detection of a camera.",
"name": "Enable camera motion detection" "name": "Enable motion detection"
}, },
"play_stream": { "play_stream": {
"description": "Plays a camera stream on a supported media player.", "description": "Plays a camera stream on a supported media player.",
@@ -69,7 +69,7 @@
"name": "Media player" "name": "Media player"
} }
}, },
"name": "Play camera stream" "name": "Play stream"
}, },
"record": { "record": {
"description": "Creates a recording of a live camera feed.", "description": "Creates a recording of a live camera feed.",
@@ -87,7 +87,7 @@
"name": "Lookback" "name": "Lookback"
} }
}, },
"name": "Record camera feed" "name": "Record"
}, },
"snapshot": { "snapshot": {
"description": "Takes a snapshot from a camera.", "description": "Takes a snapshot from a camera.",
@@ -97,15 +97,15 @@
"name": "Filename" "name": "Filename"
} }
}, },
"name": "Take camera snapshot" "name": "Take snapshot"
}, },
"turn_off": { "turn_off": {
"description": "Turns off a camera.", "description": "Turns off a camera.",
"name": "Turn off camera" "name": "[%key:common::action::turn_off%]"
}, },
"turn_on": { "turn_on": {
"description": "Turns on a camera.", "description": "Turns on a camera.",
"name": "Turn on camera" "name": "[%key:common::action::turn_on%]"
} }
}, },
"title": "Camera" "title": "Camera"

View File

@@ -1,44 +0,0 @@
"""The Casper Glow integration."""
from __future__ import annotations
from pycasperglow import CasperGlow
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.SELECT,
]
async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:
"""Set up Casper Glow from a config entry."""
address: str = entry.data[CONF_ADDRESS]
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True)
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find Casper Glow device with address {address}"
)
glow = CasperGlow(ble_device)
coordinator = CasperGlowCoordinator(hass, glow, entry.title)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(coordinator.async_start())
return True
async def async_unload_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,51 +0,0 @@
"""Casper Glow integration binary sensor platform."""
from __future__ import annotations
from pycasperglow import GlowState
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary sensor platform for Casper Glow."""
async_add_entities([CasperGlowPausedBinarySensor(entry.runtime_data)])
class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity):
"""Binary sensor indicating whether the Casper Glow dimming is paused."""
_attr_translation_key = "paused"
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize the paused binary sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_paused"
if coordinator.device.state.is_paused is not None:
self._attr_is_on = coordinator.device.state.is_paused
async def async_added_to_hass(self) -> None:
"""Register state update callback when entity is added."""
await super().async_added_to_hass()
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
if state.is_paused is not None:
self._attr_is_on = state.is_paused
self.async_write_ha_state()

View File

@@ -1,73 +0,0 @@
"""Casper Glow integration button platform."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pycasperglow import CasperGlow
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class CasperGlowButtonEntityDescription(ButtonEntityDescription):
"""Describe a Casper Glow button entity."""
press_fn: Callable[[CasperGlow], Awaitable[None]]
BUTTON_DESCRIPTIONS: tuple[CasperGlowButtonEntityDescription, ...] = (
CasperGlowButtonEntityDescription(
key="pause",
translation_key="pause",
press_fn=lambda device: device.pause(),
),
CasperGlowButtonEntityDescription(
key="resume",
translation_key="resume",
press_fn=lambda device: device.resume(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the button platform for Casper Glow."""
async_add_entities(
CasperGlowButton(entry.runtime_data, description)
for description in BUTTON_DESCRIPTIONS
)
class CasperGlowButton(CasperGlowEntity, ButtonEntity):
"""A Casper Glow button entity."""
entity_description: CasperGlowButtonEntityDescription
def __init__(
self,
coordinator: CasperGlowCoordinator,
description: CasperGlowButtonEntityDescription,
) -> None:
"""Initialize a Casper Glow button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = (
f"{format_mac(coordinator.device.address)}_{description.key}"
)
async def async_press(self) -> None:
"""Press the button."""
await self._async_command(self.entity_description.press_fn(self._device))

View File

@@ -1,151 +0,0 @@
"""Config flow for Casper Glow integration."""
from __future__ import annotations
import logging
from typing import Any
from bluetooth_data_tools import human_readable_name
from pycasperglow import CasperGlow, CasperGlowError
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN, LOCAL_NAMES
_LOGGER = logging.getLogger(__name__)
class CasperGlowConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Casper Glow."""
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(format_mac(discovery_info.address))
self._abort_if_unique_id_configured()
self._discovery_info = discovery_info
self.context["title_placeholders"] = {
"name": human_readable_name(
None, discovery_info.name, discovery_info.address
)
}
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm a discovered Casper Glow device."""
assert self._discovery_info is not None
if user_input is not None:
return self.async_create_entry(
title=self.context["title_placeholders"]["name"],
data={CONF_ADDRESS: self._discovery_info.address},
)
glow = CasperGlow(self._discovery_info.device)
try:
await glow.handshake()
except CasperGlowError:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception(
"Unexpected error during Casper Glow config flow "
"(step=bluetooth_confirm, address=%s)",
self._discovery_info.address,
)
return self.async_abort(reason="unknown")
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders=self.context["title_placeholders"],
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
address = user_input[CONF_ADDRESS]
discovery_info = self._discovered_devices[address]
await self.async_set_unique_id(
format_mac(discovery_info.address), raise_on_progress=False
)
self._abort_if_unique_id_configured()
glow = CasperGlow(discovery_info.device)
try:
await glow.handshake()
except CasperGlowError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception(
"Unexpected error during Casper Glow config flow "
"(step=user, address=%s)",
discovery_info.address,
)
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=human_readable_name(
None, discovery_info.name, discovery_info.address
),
data={
CONF_ADDRESS: discovery_info.address,
},
)
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
format_mac(discovery.address) in current_addresses
or discovery.address in self._discovered_devices
or not (
discovery.name
and any(
discovery.name.startswith(local_name)
for local_name in LOCAL_NAMES
)
)
):
continue
self._discovered_devices[discovery.address] = discovery
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
data_schema = vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(
{
service_info.address: human_readable_name(
None, service_info.name, service_info.address
)
for service_info in self._discovered_devices.values()
}
),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)

View File

@@ -1,18 +0,0 @@
"""Constants for the Casper Glow integration."""
from datetime import timedelta
from pycasperglow import BRIGHTNESS_LEVELS, DEVICE_NAME_PREFIX, DIMMING_TIME_MINUTES
DOMAIN = "casper_glow"
LOCAL_NAMES = {DEVICE_NAME_PREFIX}
SORTED_BRIGHTNESS_LEVELS = sorted(BRIGHTNESS_LEVELS)
DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0]
DIMMING_TIME_OPTIONS: tuple[str, ...] = tuple(str(m) for m in DIMMING_TIME_MINUTES)
# Interval between periodic state polls to catch externally-triggered changes.
STATE_POLL_INTERVAL = timedelta(seconds=30)

View File

@@ -1,112 +0,0 @@
"""Coordinator for the Casper Glow integration."""
from __future__ import annotations
import logging
from bleak import BleakError
from bluetooth_data_tools import monotonic_time_coarse
from pycasperglow import CasperGlow
from homeassistant.components.bluetooth import (
BluetoothChange,
BluetoothScanningMode,
BluetoothServiceInfoBleak,
)
from homeassistant.components.bluetooth.active_update_coordinator import (
ActiveBluetoothDataUpdateCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from .const import SORTED_BRIGHTNESS_LEVELS, STATE_POLL_INTERVAL
_LOGGER = logging.getLogger(__name__)
type CasperGlowConfigEntry = ConfigEntry[CasperGlowCoordinator]
class CasperGlowCoordinator(ActiveBluetoothDataUpdateCoordinator[None]):
"""Coordinator for Casper Glow BLE devices."""
def __init__(
self,
hass: HomeAssistant,
device: CasperGlow,
title: str,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass=hass,
logger=_LOGGER,
address=device.address,
mode=BluetoothScanningMode.PASSIVE,
needs_poll_method=self._needs_poll,
poll_method=self._async_update,
connectable=True,
)
self.device = device
self.last_dimming_time_minutes: int | None = (
device.state.configured_dimming_time_minutes
)
self.title = title
# The device API couples brightness and dimming time into a
# single command (set_brightness_and_dimming_time), so both
# values must be tracked here for cross-entity use.
self.last_brightness_pct: int = (
device.state.brightness_level
if device.state.brightness_level is not None
else SORTED_BRIGHTNESS_LEVELS[0]
)
@callback
def _needs_poll(
self,
service_info: BluetoothServiceInfoBleak,
seconds_since_last_poll: float | None,
) -> bool:
"""Return True if a poll is needed."""
return (
seconds_since_last_poll is None
or seconds_since_last_poll >= STATE_POLL_INTERVAL.total_seconds()
)
async def _async_update(self, service_info: BluetoothServiceInfoBleak) -> None:
"""Poll device state."""
await self.device.query_state()
async def _async_poll(self) -> None:
"""Poll the device and log availability changes."""
assert self._last_service_info
try:
await self._async_poll_data(self._last_service_info)
except BleakError as exc:
if self.last_poll_successful:
_LOGGER.info("%s is unavailable: %s", self.title, exc)
self.last_poll_successful = False
return
except Exception:
if self.last_poll_successful:
_LOGGER.exception("%s: unexpected error while polling", self.title)
self.last_poll_successful = False
return
finally:
self._last_poll = monotonic_time_coarse()
if not self.last_poll_successful:
_LOGGER.info("%s is back online", self.title)
self.last_poll_successful = True
self._async_handle_bluetooth_poll()
@callback
def _async_handle_bluetooth_event(
self,
service_info: BluetoothServiceInfoBleak,
change: BluetoothChange,
) -> None:
"""Update BLE device reference on each advertisement."""
self.device.set_ble_device(service_info.device)
super()._async_handle_bluetooth_event(service_info, change)

View File

@@ -1,47 +0,0 @@
"""Base entity for the Casper Glow integration."""
from __future__ import annotations
from collections.abc import Awaitable
from pycasperglow import CasperGlowError
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from .const import DOMAIN
from .coordinator import CasperGlowCoordinator
class CasperGlowEntity(PassiveBluetoothCoordinatorEntity[CasperGlowCoordinator]):
"""Base class for Casper Glow entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize a Casper Glow entity."""
super().__init__(coordinator)
self._device = coordinator.device
self._attr_device_info = DeviceInfo(
manufacturer="Casper",
model="Glow",
model_id="G01",
connections={
(dr.CONNECTION_BLUETOOTH, format_mac(coordinator.device.address))
},
)
async def _async_command(self, coro: Awaitable[None]) -> None:
"""Execute a device command."""
try:
await coro
except CasperGlowError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(err)},
) from err

View File

@@ -1,22 +0,0 @@
{
"entity": {
"binary_sensor": {
"paused": {
"default": "mdi:timer-pause"
}
},
"button": {
"pause": {
"default": "mdi:pause"
},
"resume": {
"default": "mdi:play"
}
},
"select": {
"dimming_time": {
"default": "mdi:timer-outline"
}
}
}
}

View File

@@ -1,106 +0,0 @@
"""Casper Glow integration light platform."""
from __future__ import annotations
from typing import Any
from pycasperglow import GlowState
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
)
from .const import DEFAULT_DIMMING_TIME_MINUTES, SORTED_BRIGHTNESS_LEVELS
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 1
def _ha_brightness_to_device_pct(brightness: int) -> int:
"""Convert HA brightness (1-255) to device percentage by snapping to nearest."""
return percentage_to_ordered_list_item(
SORTED_BRIGHTNESS_LEVELS, round(brightness * 100 / 255)
)
def _device_pct_to_ha_brightness(pct: int) -> int:
"""Convert device brightness percentage (60-100) to HA brightness (1-255)."""
percent = ordered_list_item_to_percentage(SORTED_BRIGHTNESS_LEVELS, pct)
return round(percent * 255 / 100)
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the light platform for Casper Glow."""
async_add_entities([CasperGlowLight(entry.runtime_data)])
class CasperGlowLight(CasperGlowEntity, LightEntity):
"""Representation of a Casper Glow light."""
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_name = None
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize a Casper Glow light."""
super().__init__(coordinator)
self._attr_unique_id = format_mac(coordinator.device.address)
self._update_from_state(coordinator.device.state)
async def async_added_to_hass(self) -> None:
"""Register state update callback when entity is added."""
await super().async_added_to_hass()
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
@callback
def _update_from_state(self, state: GlowState) -> None:
"""Update entity attributes from device state."""
if state.is_on is not None:
self._attr_is_on = state.is_on
self._attr_color_mode = ColorMode.BRIGHTNESS
if state.brightness_level is not None:
self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level)
self.coordinator.last_brightness_pct = state.brightness_level
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
self._update_from_state(state)
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
brightness_pct: int | None = None
if ATTR_BRIGHTNESS in kwargs:
brightness_pct = _ha_brightness_to_device_pct(kwargs[ATTR_BRIGHTNESS])
await self._async_command(self._device.turn_on())
self._attr_is_on = True
self._attr_color_mode = ColorMode.BRIGHTNESS
if brightness_pct is not None:
await self._async_command(
self._device.set_brightness_and_dimming_time(
brightness_pct,
self.coordinator.last_dimming_time_minutes
if self.coordinator.last_dimming_time_minutes is not None
else DEFAULT_DIMMING_TIME_MINUTES,
)
)
self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct)
self.coordinator.last_brightness_pct = brightness_pct
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self._async_command(self._device.turn_off())
self._attr_is_on = False

View File

@@ -1,19 +0,0 @@
{
"domain": "casper_glow",
"name": "Casper Glow",
"bluetooth": [
{
"connectable": true,
"local_name": "Jar*"
}
],
"codeowners": ["@mikeodr"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/casper_glow",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pycasperglow"],
"quality_scale": "silver",
"requirements": ["pycasperglow==1.1.0"]
}

View File

@@ -1,72 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No custom services.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No custom actions/services.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Bluetooth device with no authentication credentials.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: No network discovery.
discovery: done
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class:
status: exempt
comment: No applicable device classes for binary_sensor, button, light, or select entities.
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: No web session is used by this integration.
strict-typing: done

View File

@@ -1,92 +0,0 @@
"""Casper Glow integration select platform for dimming time."""
from __future__ import annotations
from pycasperglow import GlowState
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DIMMING_TIME_OPTIONS
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the select platform for Casper Glow."""
async_add_entities([CasperGlowDimmingTimeSelect(entry.runtime_data)])
class CasperGlowDimmingTimeSelect(CasperGlowEntity, SelectEntity, RestoreEntity):
"""Select entity for Casper Glow dimming time."""
_attr_translation_key = "dimming_time"
_attr_entity_category = EntityCategory.CONFIG
_attr_options = list(DIMMING_TIME_OPTIONS)
_attr_unit_of_measurement = UnitOfTime.MINUTES
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize the dimming time select entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_dimming_time"
@property
def current_option(self) -> str | None:
"""Return the currently selected dimming time from the coordinator."""
if self.coordinator.last_dimming_time_minutes is None:
return None
return str(self.coordinator.last_dimming_time_minutes)
async def async_added_to_hass(self) -> None:
"""Restore last known dimming time and register state update callback."""
await super().async_added_to_hass()
if self.coordinator.last_dimming_time_minutes is None and (
last_state := await self.async_get_last_state()
):
if last_state.state in DIMMING_TIME_OPTIONS:
self.coordinator.last_dimming_time_minutes = int(last_state.state)
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
if state.brightness_level is not None:
self.coordinator.last_brightness_pct = state.brightness_level
if (
state.configured_dimming_time_minutes is not None
and self.coordinator.last_dimming_time_minutes is None
):
self.coordinator.last_dimming_time_minutes = (
state.configured_dimming_time_minutes
)
# Dimming time is not part of the device state
# that is provided via BLE update. Therefore
# we need to trigger a state update for the select entity
# to update the current state.
self.async_write_ha_state()
async def async_select_option(self, option: str) -> None:
"""Set the dimming time."""
await self._async_command(
self._device.set_brightness_and_dimming_time(
self.coordinator.last_brightness_pct, int(option)
)
)
self.coordinator.last_dimming_time_minutes = int(option)
# Dimming time is not part of the device state
# that is provided via BLE update. Therefore
# we need to trigger a state update for the select entity
# to update the current state.
self.async_write_ha_state()

View File

@@ -1,54 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name}",
"step": {
"bluetooth_confirm": {
"description": "Do you want to set up {name}?"
},
"user": {
"data": {
"address": "Bluetooth address"
},
"data_description": {
"address": "The Bluetooth address of the Casper Glow light"
}
}
}
},
"entity": {
"binary_sensor": {
"paused": {
"name": "Dimming paused"
}
},
"button": {
"pause": {
"name": "Pause dimming"
},
"resume": {
"name": "Resume dimming"
}
},
"select": {
"dimming_time": {
"name": "Dimming time"
}
}
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the Casper Glow: {error}"
}
}
}

View File

@@ -1,68 +1,20 @@
{ {
"entity": { "entity": {
"sensor": { "sensor": {
"chess960_daily_draw": {
"default": "mdi:chess-pawn"
},
"chess960_daily_lost": {
"default": "mdi:chess-pawn"
},
"chess960_daily_rating": {
"default": "mdi:chart-line"
},
"chess960_daily_won": {
"default": "mdi:chess-pawn"
},
"chess_blitz_draw": {
"default": "mdi:chess-pawn"
},
"chess_blitz_lost": {
"default": "mdi:chess-pawn"
},
"chess_blitz_rating": {
"default": "mdi:chart-line"
},
"chess_blitz_won": {
"default": "mdi:chess-pawn"
},
"chess_bullet_draw": {
"default": "mdi:chess-pawn"
},
"chess_bullet_lost": {
"default": "mdi:chess-pawn"
},
"chess_bullet_rating": {
"default": "mdi:chart-line"
},
"chess_bullet_won": {
"default": "mdi:chess-pawn"
},
"chess_daily_draw": {
"default": "mdi:chess-pawn"
},
"chess_daily_lost": {
"default": "mdi:chess-pawn"
},
"chess_daily_rating": { "chess_daily_rating": {
"default": "mdi:chart-line" "default": "mdi:chart-line"
}, },
"chess_daily_won": {
"default": "mdi:chess-pawn"
},
"chess_rapid_draw": {
"default": "mdi:chess-pawn"
},
"chess_rapid_lost": {
"default": "mdi:chess-pawn"
},
"chess_rapid_rating": {
"default": "mdi:chart-line"
},
"chess_rapid_won": {
"default": "mdi:chess-pawn"
},
"followers": { "followers": {
"default": "mdi:account-multiple" "default": "mdi:account-multiple"
},
"total_daily_draw": {
"default": "mdi:chess-pawn"
},
"total_daily_lost": {
"default": "mdi:chess-pawn"
},
"total_daily_won": {
"default": "mdi:chess-pawn"
} }
} }
} }

View File

@@ -2,9 +2,6 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from chess_com_api import PlayerStats
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
@@ -27,14 +24,7 @@ class ChessEntityDescription(SensorEntityDescription):
value_fn: Callable[[ChessData], float] value_fn: Callable[[ChessData], float]
@dataclass(kw_only=True, frozen=True) SENSORS: tuple[ChessEntityDescription, ...] = (
class ChessModeEntityDescription(SensorEntityDescription):
"""Sensor description for a Chess.com game mode."""
value_fn: Callable[[dict[str, Any]], float]
PLAYER_SENSORS: tuple[ChessEntityDescription, ...] = (
ChessEntityDescription( ChessEntityDescription(
key="followers", key="followers",
translation_key="followers", translation_key="followers",
@@ -43,46 +33,35 @@ PLAYER_SENSORS: tuple[ChessEntityDescription, ...] = (
value_fn=lambda state: state.player.followers, value_fn=lambda state: state.player.followers,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
) ChessEntityDescription(
key="chess_daily_rating",
GAME_MODE_SENSORS: tuple[ChessModeEntityDescription, ...] = ( translation_key="chess_daily_rating",
ChessModeEntityDescription(
key="rating",
translation_key="rating",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda mode: mode["last"]["rating"], value_fn=lambda state: state.stats.chess_daily["last"]["rating"],
), ),
ChessModeEntityDescription( ChessEntityDescription(
key="won", key="total_daily_won",
translation_key="won", translation_key="total_daily_won",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda mode: mode["record"]["win"], value_fn=lambda state: state.stats.chess_daily["record"]["win"],
), ),
ChessModeEntityDescription( ChessEntityDescription(
key="lost", key="total_daily_lost",
translation_key="lost", translation_key="total_daily_lost",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda mode: mode["record"]["loss"], value_fn=lambda state: state.stats.chess_daily["record"]["loss"],
), ),
ChessModeEntityDescription( ChessEntityDescription(
key="draw", key="total_daily_draw",
translation_key="draw", translation_key="total_daily_draw",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda mode: mode["record"]["draw"], value_fn=lambda state: state.stats.chess_daily["record"]["draw"],
), ),
) )
GAME_MODES: dict[str, Callable[[PlayerStats], dict[str, Any] | None]] = {
"chess_daily": lambda stats: stats.chess_daily,
"chess_rapid": lambda stats: stats.chess_rapid,
"chess_bullet": lambda stats: stats.chess_bullet,
"chess_blitz": lambda stats: stats.chess_blitz,
"chess960_daily": lambda stats: stats.chess960_daily,
}
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@@ -92,22 +71,13 @@ async def async_setup_entry(
"""Initialize the entries.""" """Initialize the entries."""
coordinator = entry.runtime_data coordinator = entry.runtime_data
entities: list[SensorEntity] = [ async_add_entities(
ChessPlayerSensor(coordinator, description) for description in PLAYER_SENSORS ChessPlayerSensor(coordinator, description) for description in SENSORS
] )
for game_mode, stats_fn in GAME_MODES.items():
if stats_fn(coordinator.data.stats) is not None:
entities.extend(
ChessGameModeSensor(coordinator, description, game_mode, stats_fn)
for description in GAME_MODE_SENSORS
)
async_add_entities(entities)
class ChessPlayerSensor(ChessEntity, SensorEntity): class ChessPlayerSensor(ChessEntity, SensorEntity):
"""Chess.com player sensor.""" """Chess.com sensor."""
entity_description: ChessEntityDescription entity_description: ChessEntityDescription
@@ -125,33 +95,3 @@ class ChessPlayerSensor(ChessEntity, SensorEntity):
def native_value(self) -> float: def native_value(self) -> float:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data) return self.entity_description.value_fn(self.coordinator.data)
class ChessGameModeSensor(ChessEntity, SensorEntity):
"""Chess.com game mode sensor."""
entity_description: ChessModeEntityDescription
def __init__(
self,
coordinator: ChessCoordinator,
description: ChessModeEntityDescription,
game_mode: str,
stats_fn: Callable[[PlayerStats], dict[str, Any] | None],
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._stats_fn = stats_fn
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}.{game_mode}.{description.key}"
)
self._attr_translation_key = f"{game_mode}_{description.translation_key}"
@property
def native_value(self) -> float:
"""Return the state of the sensor."""
mode_data = self._stats_fn(self.coordinator.data.stats)
if TYPE_CHECKING:
assert mode_data is not None
return self.entity_description.value_fn(mode_data)

View File

@@ -23,84 +23,24 @@
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"chess960_daily_draw": {
"name": "Total daily Chess960 games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess960_daily_lost": {
"name": "Total daily Chess960 games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess960_daily_rating": {
"name": "Daily Chess960 rating"
},
"chess960_daily_won": {
"name": "Total daily Chess960 games won",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_blitz_draw": {
"name": "Total blitz chess games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_blitz_lost": {
"name": "Total blitz chess games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_blitz_rating": {
"name": "Blitz chess rating"
},
"chess_blitz_won": {
"name": "Total blitz chess games won",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_bullet_draw": {
"name": "Total bullet chess games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_bullet_lost": {
"name": "Total bullet chess games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_bullet_rating": {
"name": "Bullet chess rating"
},
"chess_bullet_won": {
"name": "Total bullet chess games won",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_daily_draw": {
"name": "Total daily chess games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_daily_lost": {
"name": "Total daily chess games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_daily_rating": { "chess_daily_rating": {
"name": "Daily chess rating" "name": "Daily chess rating"
}, },
"chess_daily_won": {
"name": "Total daily chess games won",
"unit_of_measurement": "games"
},
"chess_rapid_draw": {
"name": "Total rapid chess games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_rapid_lost": {
"name": "Total rapid chess games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_rapid_rating": {
"name": "Rapid chess rating"
},
"chess_rapid_won": {
"name": "Total rapid chess games won",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"followers": { "followers": {
"name": "Followers", "name": "Followers",
"unit_of_measurement": "followers" "unit_of_measurement": "followers"
},
"total_daily_draw": {
"name": "Total chess games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]"
},
"total_daily_lost": {
"name": "Total chess games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]"
},
"total_daily_won": {
"name": "Total chess games won",
"unit_of_measurement": "games"
} }
} }
} }

View File

@@ -1,72 +1,12 @@
"""Provides conditions for climates.""" """Provides conditions for climates."""
from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant
import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import ( from homeassistant.helpers.condition import Condition, make_entity_state_condition
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode"
_HVAC_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All(
cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)]
),
},
}
)
class ClimateHVACModeCondition(EntityConditionBase):
"""Condition for climate HVAC mode."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = _HVAC_MODE_CONDITION_SCHEMA
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize the HVAC mode condition."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._hvac_modes: set[str] = set(config.options[CONF_HVAC_MODE])
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches any of the expected HVAC modes."""
return entity_state.state in self._hvac_modes
class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
"""Mixin for climate target temperature conditions with unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONDITIONS: dict[str, type[Condition]] = { CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF), "is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
"is_on": make_entity_state_condition( "is_on": make_entity_state_condition(
DOMAIN, DOMAIN,
@@ -88,11 +28,6 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition( "is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
), ),
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature": ClimateTargetTemperatureCondition,
} }

View File

@@ -1,9 +1,9 @@
.condition_common: &condition_common .condition_common: &condition_common
target: &condition_climate_target target:
entity: entity:
domain: climate domain: climate
fields: fields:
behavior: &condition_behavior behavior:
required: true required: true
default: any default: any
selector: selector:
@@ -13,75 +13,8 @@
- all - all
- any - any
.humidity_threshold_entity: &humidity_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: sensor
device_class: humidity
- domain: number
device_class: humidity
.humidity_threshold_number: &humidity_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
.temperature_units: &temperature_units
- "°C"
- "°F"
.temperature_threshold_entity: &temperature_threshold_entity
- domain: input_number
unit_of_measurement: *temperature_units
- domain: sensor
device_class: temperature
- domain: number
device_class: temperature
is_off: *condition_common is_off: *condition_common
is_on: *condition_common is_on: *condition_common
is_cooling: *condition_common is_cooling: *condition_common
is_drying: *condition_common is_drying: *condition_common
is_heating: *condition_common is_heating: *condition_common
is_hvac_mode:
target: *condition_climate_target
fields:
behavior: *condition_behavior
hvac_mode:
context:
filter_target: target
required: true
selector:
state:
hide_states:
- unavailable
- unknown
multiple: true
target_humidity:
target: *condition_climate_target
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *humidity_threshold_entity
mode: is
number: *humidity_threshold_number
target_temperature:
target: *condition_climate_target
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *temperature_threshold_entity
mode: is
number:
mode: box
unit_of_measurement: *temperature_units

View File

@@ -9,20 +9,11 @@
"is_heating": { "is_heating": {
"condition": "mdi:fire" "condition": "mdi:fire"
}, },
"is_hvac_mode": {
"condition": "mdi:thermostat"
},
"is_off": { "is_off": {
"condition": "mdi:power-off" "condition": "mdi:power-off"
}, },
"is_on": { "is_on": {
"condition": "mdi:power-on" "condition": "mdi:power-on"
},
"target_humidity": {
"condition": "mdi:water-percent"
},
"target_temperature": {
"condition": "mdi:thermometer"
} }
}, },
"entity_component": { "entity_component": {

View File

@@ -11,8 +11,7 @@ set_preset_mode:
required: true required: true
example: "away" example: "away"
selector: selector:
state: text:
attribute: preset_mode
set_temperature: set_temperature:
target: target:
@@ -56,10 +55,16 @@ set_temperature:
mode: box mode: box
hvac_mode: hvac_mode:
selector: selector:
state: select:
hide_states: options:
- unavailable - "off"
- unknown - "auto"
- "cool"
- "dry"
- "fan_only"
- "heat_cool"
- "heat"
translation_key: hvac_mode
set_humidity: set_humidity:
target: target:
entity: entity:
@@ -86,8 +91,7 @@ set_fan_mode:
required: true required: true
example: "low" example: "low"
selector: selector:
state: text:
attribute: fan_mode
set_hvac_mode: set_hvac_mode:
target: target:
@@ -111,8 +115,7 @@ set_swing_mode:
required: true required: true
example: "on" example: "on"
selector: selector:
state: text:
attribute: swing_mode
set_swing_horizontal_mode: set_swing_horizontal_mode:
target: target:
@@ -125,8 +128,7 @@ set_swing_horizontal_mode:
required: true required: true
example: "on" example: "on"
selector: selector:
state: text:
attribute: swing_horizontal_mode
turn_on: turn_on:
target: target:

View File

@@ -2,13 +2,8 @@
"common": { "common": {
"condition_behavior_description": "How the state should match on the targeted climate-control devices.", "condition_behavior_description": "How the state should match on the targeted climate-control devices.",
"condition_behavior_name": "Behavior", "condition_behavior_name": "Behavior",
"condition_threshold_description": "What to test for and threshold values.",
"condition_threshold_name": "Threshold configuration",
"trigger_behavior_description": "The behavior of the targeted climates to trigger on.", "trigger_behavior_description": "The behavior of the targeted climates to trigger on.",
"trigger_behavior_name": "Behavior", "trigger_behavior_name": "Behavior"
"trigger_threshold_changed_description": "Which changes to trigger on and threshold values.",
"trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.",
"trigger_threshold_name": "Threshold configuration"
}, },
"conditions": { "conditions": {
"is_cooling": { "is_cooling": {
@@ -41,20 +36,6 @@
}, },
"name": "Climate-control device is heating" "name": "Climate-control device is heating"
}, },
"is_hvac_mode": {
"description": "Tests if one or more climate-control devices are set to a specific HVAC mode.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"hvac_mode": {
"description": "The HVAC modes to test for.",
"name": "Modes"
}
},
"name": "Climate-control device HVAC mode"
},
"is_off": { "is_off": {
"description": "Tests if one or more climate-control devices are off.", "description": "Tests if one or more climate-control devices are off.",
"fields": { "fields": {
@@ -74,34 +55,6 @@
} }
}, },
"name": "Climate-control device is on" "name": "Climate-control device is on"
},
"target_humidity": {
"description": "Tests the humidity setpoint of one or more climate-control devices.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::climate::common::condition_threshold_description%]",
"name": "[%key:component::climate::common::condition_threshold_name%]"
}
},
"name": "Climate-control device target humidity"
},
"target_temperature": {
"description": "Tests the temperature setpoint of one or more climate-control devices.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::climate::common::condition_threshold_description%]",
"name": "[%key:component::climate::common::condition_threshold_name%]"
}
},
"name": "Climate-control device target temperature"
} }
}, },
"device_automation": { "device_automation": {
@@ -288,77 +241,102 @@
"any": "Any" "any": "Any"
} }
}, },
"hvac_mode": {
"options": {
"auto": "[%key:common::state::auto%]",
"cool": "Cool",
"dry": "Dry",
"fan_only": "Fan only",
"heat": "Heat",
"heat_cool": "Heat/cool",
"off": "[%key:common::state::off%]"
}
},
"number_or_entity": {
"choices": {
"entity": "Entity",
"number": "Number"
}
},
"trigger_behavior": { "trigger_behavior": {
"options": { "options": {
"any": "Any", "any": "Any",
"first": "First", "first": "First",
"last": "Last" "last": "Last"
} }
},
"trigger_threshold_type": {
"options": {
"above": "Above a value",
"below": "Below a value",
"between": "In a range",
"outside": "Outside a range"
}
} }
}, },
"services": { "services": {
"set_fan_mode": { "set_fan_mode": {
"description": "Sets the fan mode of a climate-control device.", "description": "Sets fan operation mode.",
"fields": { "fields": {
"fan_mode": { "fan_mode": {
"description": "Fan operation mode.", "description": "Fan operation mode.",
"name": "Fan mode" "name": "Fan mode"
} }
}, },
"name": "Set climate-control device fan mode" "name": "Set fan mode"
}, },
"set_humidity": { "set_humidity": {
"description": "Sets the target humidity of a climate-control device.", "description": "Sets target humidity.",
"fields": { "fields": {
"humidity": { "humidity": {
"description": "Target humidity.", "description": "Target humidity.",
"name": "Humidity" "name": "Humidity"
} }
}, },
"name": "Set climate-control device target humidity" "name": "Set target humidity"
}, },
"set_hvac_mode": { "set_hvac_mode": {
"description": "Sets the HVAC mode of a climate-control device.", "description": "Sets HVAC operation mode.",
"fields": { "fields": {
"hvac_mode": { "hvac_mode": {
"description": "HVAC operation mode.", "description": "HVAC operation mode.",
"name": "HVAC mode" "name": "HVAC mode"
} }
}, },
"name": "Set climate-control device HVAC mode" "name": "Set HVAC mode"
}, },
"set_preset_mode": { "set_preset_mode": {
"description": "Sets the preset mode of a climate-control device.", "description": "Sets preset mode.",
"fields": { "fields": {
"preset_mode": { "preset_mode": {
"description": "Preset mode.", "description": "Preset mode.",
"name": "Preset mode" "name": "Preset mode"
} }
}, },
"name": "Set climate-control device preset mode" "name": "Set preset mode"
}, },
"set_swing_horizontal_mode": { "set_swing_horizontal_mode": {
"description": "Sets the horizontal swing mode of a climate-control device.", "description": "Sets horizontal swing operation mode.",
"fields": { "fields": {
"swing_horizontal_mode": { "swing_horizontal_mode": {
"description": "Horizontal swing operation mode.", "description": "Horizontal swing operation mode.",
"name": "Horizontal swing mode" "name": "Horizontal swing mode"
} }
}, },
"name": "Set climate-control device horizontal swing mode" "name": "Set horizontal swing mode"
}, },
"set_swing_mode": { "set_swing_mode": {
"description": "Sets the swing mode of a climate-control device.", "description": "Sets swing operation mode.",
"fields": { "fields": {
"swing_mode": { "swing_mode": {
"description": "Swing operation mode.", "description": "Swing operation mode.",
"name": "Swing mode" "name": "Swing mode"
} }
}, },
"name": "Set climate-control device swing mode" "name": "Set swing mode"
}, },
"set_temperature": { "set_temperature": {
"description": "Sets the target temperature of a climate-control device.", "description": "Sets the temperature setpoint.",
"fields": { "fields": {
"hvac_mode": { "hvac_mode": {
"description": "HVAC operation mode.", "description": "HVAC operation mode.",
@@ -377,19 +355,19 @@
"name": "Target temperature" "name": "Target temperature"
} }
}, },
"name": "Set climate-control device target temperature" "name": "Set target temperature"
}, },
"toggle": { "toggle": {
"description": "Toggles a climate-control device on/off.", "description": "Toggles climate device, from on to off, or off to on.",
"name": "Toggle climate-control device" "name": "[%key:common::action::toggle%]"
}, },
"turn_off": { "turn_off": {
"description": "Turns off a climate-control device.", "description": "Turns climate device off.",
"name": "Turn off climate-control device" "name": "[%key:common::action::turn_off%]"
}, },
"turn_on": { "turn_on": {
"description": "Turns on a climate-control device.", "description": "Turns climate device on.",
"name": "Turn on climate-control device" "name": "[%key:common::action::turn_on%]"
} }
}, },
"title": "Climate", "title": "Climate",
@@ -441,9 +419,13 @@
"target_humidity_changed": { "target_humidity_changed": {
"description": "Triggers after the humidity setpoint of one or more climate-control devices changes.", "description": "Triggers after the humidity setpoint of one or more climate-control devices changes.",
"fields": { "fields": {
"threshold": { "above": {
"description": "[%key:component::climate::common::trigger_threshold_changed_description%]", "description": "Trigger when the target humidity is above this value.",
"name": "[%key:component::climate::common::trigger_threshold_name%]" "name": "Above"
},
"below": {
"description": "Trigger when the target humidity is below this value.",
"name": "Below"
} }
}, },
"name": "Climate-control device target humidity changed" "name": "Climate-control device target humidity changed"
@@ -455,9 +437,17 @@
"description": "[%key:component::climate::common::trigger_behavior_description%]", "description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]" "name": "[%key:component::climate::common::trigger_behavior_name%]"
}, },
"threshold": { "lower_limit": {
"description": "[%key:component::climate::common::trigger_threshold_crossed_description%]", "description": "Lower threshold limit.",
"name": "[%key:component::climate::common::trigger_threshold_name%]" "name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
} }
}, },
"name": "Climate-control device target humidity crossed threshold" "name": "Climate-control device target humidity crossed threshold"
@@ -465,9 +455,13 @@
"target_temperature_changed": { "target_temperature_changed": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices changes.", "description": "Triggers after the temperature setpoint of one or more climate-control devices changes.",
"fields": { "fields": {
"threshold": { "above": {
"description": "[%key:component::climate::common::trigger_threshold_changed_description%]", "description": "Trigger when the target temperature is above this value.",
"name": "[%key:component::climate::common::trigger_threshold_name%]" "name": "Above"
},
"below": {
"description": "Trigger when the target temperature is below this value.",
"name": "Below"
} }
}, },
"name": "Climate-control device target temperature changed" "name": "Climate-control device target temperature changed"
@@ -479,9 +473,17 @@
"description": "[%key:component::climate::common::trigger_behavior_description%]", "description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]" "name": "[%key:component::climate::common::trigger_behavior_name%]"
}, },
"threshold": { "lower_limit": {
"description": "[%key:component::climate::common::trigger_threshold_crossed_description%]", "description": "Lower threshold limit.",
"name": "[%key:component::climate::common::trigger_threshold_name%]" "name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
} }
}, },
"name": "Climate-control device target temperature crossed threshold" "name": "Climate-control device target temperature crossed threshold"

View File

@@ -2,15 +2,12 @@
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.trigger import ( from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase, EntityTargetStateTriggerBase,
Trigger, Trigger,
TriggerConfig, TriggerConfig,
@@ -19,7 +16,6 @@ from homeassistant.helpers.trigger import (
make_entity_target_state_trigger, make_entity_target_state_trigger,
make_entity_transition_trigger, make_entity_transition_trigger,
) )
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
@@ -48,33 +44,6 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
self._to_states = set(self._options[CONF_HVAC_MODE]) self._to_states = set(self._options[CONF_HVAC_MODE])
class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase):
"""Mixin for climate target temperature triggers with unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
class ClimateTargetTemperatureChangedTrigger(
_ClimateTargetTemperatureTriggerMixin,
EntityNumericalStateChangedTriggerWithUnitBase,
):
"""Trigger for climate target temperature value changes."""
class ClimateTargetTemperatureCrossedThresholdTrigger(
_ClimateTargetTemperatureTriggerMixin,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
):
"""Trigger for climate target temperature value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = { TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger, "hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger( "started_cooling": make_entity_target_state_trigger(
@@ -84,15 +53,17 @@ TRIGGERS: dict[str, type[Trigger]] = {
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
), ),
"target_humidity_changed": make_entity_numerical_state_changed_trigger( "target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
valid_unit="%",
), ),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( "target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
valid_unit="%", ),
"target_temperature_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
),
"target_temperature_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
), ),
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF), "turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger( "turned_on": make_entity_transition_trigger(
DOMAIN, DOMAIN,

View File

@@ -14,31 +14,36 @@
- last - last
- any - any
.humidity_threshold_entity: &humidity_threshold_entity .number_or_entity: &number_or_entity
- domain: input_number required: false
unit_of_measurement: "%" selector:
- domain: sensor choose:
device_class: humidity choices:
- domain: number number:
device_class: humidity selector:
number:
mode: box
entity:
selector:
entity:
filter:
domain:
- input_number
- number
- sensor
translation_key: number_or_entity
.humidity_threshold_number: &humidity_threshold_number .trigger_threshold_type: &trigger_threshold_type
min: 0 required: true
max: 100 default: above
mode: box selector:
unit_of_measurement: "%" select:
options:
.temperature_units: &temperature_units - above
- "°C" - below
- "°F" - between
- outside
.temperature_threshold_entity: &temperature_threshold_entity translation_key: trigger_threshold_type
- domain: input_number
unit_of_measurement: *temperature_units
- domain: sensor
device_class: temperature
- domain: number
device_class: temperature
started_cooling: *trigger_common started_cooling: *trigger_common
started_drying: *trigger_common started_drying: *trigger_common
@@ -64,49 +69,27 @@ hvac_mode_changed:
target_humidity_changed: target_humidity_changed:
target: *trigger_climate_target target: *trigger_climate_target
fields: fields:
threshold: above: *number_or_entity
required: true below: *number_or_entity
selector:
numeric_threshold:
entity: *humidity_threshold_entity
mode: changed
number: *humidity_threshold_number
target_humidity_crossed_threshold: target_humidity_crossed_threshold:
target: *trigger_climate_target target: *trigger_climate_target
fields: fields:
behavior: *trigger_behavior behavior: *trigger_behavior
threshold: threshold_type: *trigger_threshold_type
required: true lower_limit: *number_or_entity
selector: upper_limit: *number_or_entity
numeric_threshold:
entity: *humidity_threshold_entity
mode: crossed
number: *humidity_threshold_number
target_temperature_changed: target_temperature_changed:
target: *trigger_climate_target target: *trigger_climate_target
fields: fields:
threshold: above: *number_or_entity
required: true below: *number_or_entity
selector:
numeric_threshold:
entity: *temperature_threshold_entity
mode: changed
number:
mode: box
unit_of_measurement: *temperature_units
target_temperature_crossed_threshold: target_temperature_crossed_threshold:
target: *trigger_climate_target target: *trigger_climate_target
fields: fields:
behavior: *trigger_behavior behavior: *trigger_behavior
threshold: threshold_type: *trigger_threshold_type
required: true lower_limit: *number_or_entity
selector: upper_limit: *number_or_entity
numeric_threshold:
entity: *temperature_threshold_entity
mode: crossed
number:
mode: box
unit_of_measurement: *temperature_units

View File

@@ -138,7 +138,6 @@ class CloudBackupAgent(BackupAgent):
base64md5hash=base64md5hash, base64md5hash=base64md5hash,
metadata=metadata, metadata=metadata,
size=size, size=size,
on_progress=on_progress,
) )
break break
except CloudApiNonRetryableError as err: except CloudApiNonRetryableError as err:

View File

@@ -13,6 +13,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"], "loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==2.2.0", "openai==2.21.0"], "requirements": ["hass-nabucasa==2.0.0", "openai==2.21.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -75,11 +75,11 @@
"services": { "services": {
"remote_connect": { "remote_connect": {
"description": "Makes the instance UI accessible from outside of the local network by enabling your Home Assistant Cloud connection.", "description": "Makes the instance UI accessible from outside of the local network by enabling your Home Assistant Cloud connection.",
"name": "Enable Home Assistant Cloud remote access" "name": "Enable remote access"
}, },
"remote_disconnect": { "remote_disconnect": {
"description": "Disconnects the instance UI from Home Assistant Cloud. This disables access to it from outside your local network.", "description": "Disconnects the instance UI from Home Assistant Cloud. This disables access to it from outside your local network.",
"name": "Disable Home Assistant Cloud remote access" "name": "Disable remote access"
} }
}, },
"system_health": { "system_health": {

View File

@@ -144,7 +144,7 @@ class R2BackupAgent(BackupAgent):
if backup.size < MULTIPART_MIN_PART_SIZE_BYTES: if backup.size < MULTIPART_MIN_PART_SIZE_BYTES:
await self._upload_simple(tar_filename, open_stream) await self._upload_simple(tar_filename, open_stream)
else: else:
await self._upload_multipart(tar_filename, open_stream, on_progress) await self._upload_multipart(tar_filename, open_stream)
# Upload the metadata file # Upload the metadata file
metadata_content = json.dumps(backup.as_dict()) metadata_content = json.dumps(backup.as_dict())
@@ -185,13 +185,11 @@ class R2BackupAgent(BackupAgent):
self, self,
tar_filename: str, tar_filename: str,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
on_progress: OnProgressCallback, ):
) -> None:
"""Upload a large file using multipart upload. """Upload a large file using multipart upload.
:param tar_filename: The target filename for the backup. :param tar_filename: The target filename for the backup.
:param open_stream: A function returning an async iterator that yields bytes. :param open_stream: A function returning an async iterator that yields bytes.
:param on_progress: A callback to report the number of uploaded bytes.
""" """
_LOGGER.debug("Starting multipart upload for %s", tar_filename) _LOGGER.debug("Starting multipart upload for %s", tar_filename)
key = self._with_prefix(tar_filename) key = self._with_prefix(tar_filename)
@@ -205,7 +203,6 @@ class R2BackupAgent(BackupAgent):
part_number = 1 part_number = 1
buffer = bytearray() # bytes buffer to store the data buffer = bytearray() # bytes buffer to store the data
offset = 0 # start index of unread data inside buffer offset = 0 # start index of unread data inside buffer
bytes_uploaded = 0
stream = await open_stream() stream = await open_stream()
async for chunk in stream: async for chunk in stream:
@@ -234,8 +231,6 @@ class R2BackupAgent(BackupAgent):
Body=part_data.tobytes(), Body=part_data.tobytes(),
) )
parts.append({"PartNumber": part_number, "ETag": part["ETag"]}) parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(part_data)
on_progress(bytes_uploaded=bytes_uploaded)
part_number += 1 part_number += 1
finally: finally:
view.release() view.release()
@@ -264,8 +259,6 @@ class R2BackupAgent(BackupAgent):
Body=remaining_data.tobytes(), Body=remaining_data.tobytes(),
) )
parts.append({"PartNumber": part_number, "ETag": part["ETag"]}) parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(remaining_data)
on_progress(bytes_uploaded=bytes_uploaded)
await cast(Any, self._client).complete_multipart_upload( await cast(Any, self._client).complete_multipart_upload(
Bucket=self._bucket, Bucket=self._bucket,

View File

@@ -9,6 +9,7 @@ from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE from aiocomelit.const import CLIMATE
from homeassistant.components.climate import ( from homeassistant.components.climate import (
DOMAIN as CLIMATE_DOMAIN,
ClimateEntity, ClimateEntity,
ClimateEntityFeature, ClimateEntityFeature,
HVACAction, HVACAction,
@@ -91,7 +92,7 @@ async def async_setup_entry(
entities: list[ClimateEntity] = [] entities: list[ClimateEntity] = []
for device in coordinator.data[CLIMATE].values(): for device in coordinator.data[CLIMATE].values():
values = load_api_data(device, "climate") values = load_api_data(device, CLIMATE_DOMAIN)
if values[0] == 0 and values[4] == 0: if values[0] == 0 and values[4] == 0:
# No climate data, device is only a humidifier/dehumidifier # No climate data, device is only a humidifier/dehumidifier
@@ -139,7 +140,7 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
def _update_attributes(self) -> None: def _update_attributes(self) -> None:
"""Update class attributes.""" """Update class attributes."""
device = self.coordinator.data[CLIMATE][self._device.index] device = self.coordinator.data[CLIMATE][self._device.index]
values = load_api_data(device, "climate") values = load_api_data(device, CLIMATE_DOMAIN)
_active = values[1] _active = values[1]
_mode = values[2] # Values from API: "O", "L", "U" _mode = values[2] # Values from API: "O", "L", "U"

View File

@@ -9,6 +9,7 @@ from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE from aiocomelit.const import CLIMATE
from homeassistant.components.humidifier import ( from homeassistant.components.humidifier import (
DOMAIN as HUMIDIFIER_DOMAIN,
MODE_AUTO, MODE_AUTO,
MODE_NORMAL, MODE_NORMAL,
HumidifierAction, HumidifierAction,
@@ -67,7 +68,7 @@ async def async_setup_entry(
entities: list[ComelitHumidifierEntity] = [] entities: list[ComelitHumidifierEntity] = []
for device in coordinator.data[CLIMATE].values(): for device in coordinator.data[CLIMATE].values():
values = load_api_data(device, "humidifier") values = load_api_data(device, HUMIDIFIER_DOMAIN)
if values[0] == 0 and values[4] == 0: if values[0] == 0 and values[4] == 0:
# No humidity data, device is only a climate # No humidity data, device is only a climate
@@ -141,7 +142,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
def _update_attributes(self) -> None: def _update_attributes(self) -> None:
"""Update class attributes.""" """Update class attributes."""
device = self.coordinator.data[CLIMATE][self._device.index] device = self.coordinator.data[CLIMATE][self._device.index]
values = load_api_data(device, "humidifier") values = load_api_data(device, HUMIDIFIER_DOMAIN)
_active = values[1] _active = values[1]
_mode = values[2] # Values from API: "O", "L", "U" _mode = values[2] # Values from API: "O", "L", "U"

View File

@@ -113,6 +113,9 @@
"humidity_while_off": { "humidity_while_off": {
"message": "Cannot change humidity while off" "message": "Cannot change humidity while off"
}, },
"invalid_clima_data": {
"message": "Invalid 'clima' data"
},
"update_failed": { "update_failed": {
"message": "Failed to update data: {error}" "message": "Failed to update data: {error}"
} }

View File

@@ -2,12 +2,13 @@
from collections.abc import Awaitable, Callable, Coroutine from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING, Any, Concatenate, Literal from typing import TYPE_CHECKING, Any, Concatenate
from aiocomelit.api import ComelitSerialBridgeObject from aiocomelit.api import ComelitSerialBridgeObject
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from aiohttp import ClientSession, CookieJar from aiohttp import ClientSession, CookieJar
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@@ -29,19 +30,17 @@ async def async_client_session(hass: HomeAssistant) -> ClientSession:
) )
def load_api_data( def load_api_data(device: ComelitSerialBridgeObject, domain: str) -> list[Any]:
device: ComelitSerialBridgeObject,
domain: Literal["climate", "humidifier"],
) -> list[Any]:
"""Load data from the API.""" """Load data from the API."""
# This function is called when the data is loaded from the API. # This function is called when the data is loaded from the API
# For climate and humidifier device.val is always a list. if not isinstance(device.val, list):
if TYPE_CHECKING: raise HomeAssistantError(
assert isinstance(device.val, list) translation_domain=domain, translation_key="invalid_clima_data"
)
# CLIMATE has a 2 item tuple: # CLIMATE has a 2 item tuple:
# - first for Clima # - first for Clima
# - second for Humidifier # - second for Humidifier
return device.val[0] if domain == "climate" else device.val[1] return device.val[0] if domain == CLIMATE_DOMAIN else device.val[1]
async def cleanup_stale_entity( async def cleanup_stale_entity(

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity", "integration_type": "entity",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.24"] "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.3"]
} }

View File

@@ -6,7 +6,7 @@
}, },
"services": { "services": {
"process": { "process": {
"description": "Sends text to a conversation agent for processing.", "description": "Launches a conversation from a transcribed text.",
"fields": { "fields": {
"agent_id": { "agent_id": {
"description": "Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands.", "description": "Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands.",
@@ -25,10 +25,10 @@
"name": "Text" "name": "Text"
} }
}, },
"name": "Process conversation" "name": "Process"
}, },
"reload": { "reload": {
"description": "Reloads the intent configuration of conversation agents.", "description": "Reloads the intent configuration.",
"fields": { "fields": {
"agent_id": { "agent_id": {
"description": "Conversation agent to reload.", "description": "Conversation agent to reload.",
@@ -39,7 +39,7 @@
"name": "[%key:common::config_flow::data::language%]" "name": "[%key:common::config_flow::data::language%]"
} }
}, },
"name": "Reload conversation agents" "name": "[%key:common::action::reload%]"
} }
}, },
"title": "Conversation" "title": "Conversation"

View File

@@ -12,22 +12,5 @@
"set_value": { "set_value": {
"service": "mdi:counter" "service": "mdi:counter"
} }
},
"triggers": {
"decremented": {
"trigger": "mdi:numeric-negative-1"
},
"incremented": {
"trigger": "mdi:numeric-positive-1"
},
"maximum_reached": {
"trigger": "mdi:sort-numeric-ascending-variant"
},
"minimum_reached": {
"trigger": "mdi:sort-numeric-descending-variant"
},
"reset": {
"trigger": "mdi:refresh"
}
} }
} }

View File

@@ -1,8 +1,4 @@
{ {
"common": {
"trigger_behavior_description": "The behavior of the targeted counters to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": { "entity_component": {
"_": { "_": {
"name": "[%key:component::counter::title%]", "name": "[%key:component::counter::title%]",
@@ -29,78 +25,29 @@
} }
} }
}, },
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": { "services": {
"decrement": { "decrement": {
"description": "Decrements a counter by its step size.", "description": "Decrements a counter by its step size.",
"name": "Decrement counter" "name": "Decrement"
}, },
"increment": { "increment": {
"description": "Increments a counter by its step size.", "description": "Increments a counter by its step size.",
"name": "Increment counter" "name": "Increment"
}, },
"reset": { "reset": {
"description": "Resets a counter to its initial value.", "description": "Resets a counter to its initial value.",
"name": "Reset counter" "name": "Reset"
}, },
"set_value": { "set_value": {
"description": "Sets a counter to a specific value.", "description": "Sets the counter to a specific value.",
"fields": { "fields": {
"value": { "value": {
"description": "The new counter value the entity should be set to.", "description": "The new counter value the entity should be set to.",
"name": "Value" "name": "Value"
} }
}, },
"name": "Set counter value" "name": "Set"
} }
}, },
"title": "Counter", "title": "Counter"
"triggers": {
"decremented": {
"description": "Triggers after one or more counters decrement.",
"name": "Counter decremented"
},
"incremented": {
"description": "Triggers after one or more counters increment.",
"name": "Counter incremented"
},
"maximum_reached": {
"description": "Triggers after one or more counters reach their maximum value.",
"fields": {
"behavior": {
"description": "[%key:component::counter::common::trigger_behavior_description%]",
"name": "[%key:component::counter::common::trigger_behavior_name%]"
}
},
"name": "Counter reached maximum"
},
"minimum_reached": {
"description": "Triggers after one or more counters reach their minimum value.",
"fields": {
"behavior": {
"description": "[%key:component::counter::common::trigger_behavior_description%]",
"name": "[%key:component::counter::common::trigger_behavior_name%]"
}
},
"name": "Counter reached minimum"
},
"reset": {
"description": "Triggers after one or more counters are reset.",
"fields": {
"behavior": {
"description": "[%key:component::counter::common::trigger_behavior_description%]",
"name": "[%key:component::counter::common::trigger_behavior_name%]"
}
},
"name": "Counter reset"
}
}
} }

View File

@@ -1,113 +0,0 @@
"""Provides triggers for counters."""
from homeassistant.const import (
CONF_MAXIMUM,
CONF_MINIMUM,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from . import CONF_INITIAL, DOMAIN
def _is_integer_state(state: State) -> bool:
"""Return True if the state's value can be interpreted as an integer."""
try:
int(state.state)
except TypeError, ValueError:
return False
return True
class CounterBaseIntegerTrigger(EntityTriggerBase):
"""Base trigger for valid counter integer states."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is valid."""
return _is_integer_state(state)
class CounterDecrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is decremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return int(from_state.state) > int(to_state.state)
class CounterIncrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is incremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return int(from_state.state) < int(to_state.state)
class CounterValueBaseTrigger(EntityTriggerBase):
"""Base trigger for counter value changes."""
_domain_specs = {DOMAIN: DomainSpec()}
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return from_state.state != to_state.state
class CounterMaxReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its maximum value."""
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
if (max_value := state.attributes.get(CONF_MAXIMUM)) is None:
return False
return state.state == str(max_value)
class CounterMinReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its minimum value."""
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
if (min_value := state.attributes.get(CONF_MINIMUM)) is None:
return False
return state.state == str(min_value)
class CounterResetTrigger(CounterValueBaseTrigger):
"""Trigger for reset of counter entities."""
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
if (init_state := state.attributes.get(CONF_INITIAL)) is None:
return False
return state.state == str(init_state)
TRIGGERS: dict[str, type[Trigger]] = {
"decremented": CounterDecrementedTrigger,
"incremented": CounterIncrementedTrigger,
"maximum_reached": CounterMaxReachedTrigger,
"minimum_reached": CounterMinReachedTrigger,
"reset": CounterResetTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for counters."""
return TRIGGERS

View File

@@ -1,23 +1,19 @@
"""Provides conditions for covers.""" """Provides conditions for covers."""
from collections.abc import Mapping
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.condition import Condition, EntityConditionBase from homeassistant.helpers.condition import Condition, EntityConditionBase
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .models import CoverDomainSpec from .models import CoverDomainSpec
class CoverConditionBase(EntityConditionBase): class CoverConditionBase(EntityConditionBase[CoverDomainSpec]):
"""Base condition for cover state checks.""" """Base condition for cover state checks."""
_domain_specs: Mapping[str, CoverDomainSpec]
def is_valid_state(self, entity_state: State) -> bool: def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches the expected cover state.""" """Check if the state matches the expected cover state."""
domain_spec = self._domain_specs[entity_state.domain] domain_spec = self._domain_specs[split_entity_id(entity_state.entity_id)[0]]
if domain_spec.value_source is not None: if domain_spec.value_source is not None:
return ( return (
entity_state.attributes.get(domain_spec.value_source) entity_state.attributes.get(domain_spec.value_source)

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