Compare commits

...

42 Commits

Author SHA1 Message Date
dependabot[bot] 8949cdd558 Bump github/gh-aw-actions from 0.74.4 to 0.74.8
Bumps [github/gh-aw-actions](https://github.com/github/gh-aw-actions) from 0.74.4 to 0.74.8.
- [Release notes](https://github.com/github/gh-aw-actions/releases)
- [Changelog](https://github.com/github/gh-aw-actions/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/gh-aw-actions/compare/d3abfe96a194bce3a523ed2093ddedd5704cdf62...efa55847f72aadb03490d955263ff911bf758700)

---
updated-dependencies:
- dependency-name: github/gh-aw-actions
  dependency-version: 0.74.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-27 07:42:24 +00:00
Petro31 7912afb765 Create issue when legacy platform setup is not supported for device_trackers (#172281) 2026-05-27 09:08:20 +02:00
Jan Bouwhuis 7adaa09333 Add override decorator for incomfort to comply with PEP 698 (#172244) 2026-05-27 08:20:16 +02:00
tronikos c5e7ed9aba Update recommended chat model to gemini-3.1-flash-lite (#172299) 2026-05-27 08:19:01 +02:00
Max Michels 68b8667998 Add missing exception translation key in aws_s3 (#172270) 2026-05-27 07:31:58 +02:00
J. Nick Koston f643dd98e5 Bump habluetooth to 6.7.9 (#172303) 2026-05-26 23:55:04 -05:00
J. Nick Koston dcec29dbbf Bump qingping-ble to 1.1.5 (#172305) 2026-05-26 22:41:55 -05:00
J. Nick Koston 1daff77591 Skip Linux only bluetooth scanner tests on non Linux platforms (#172304) 2026-05-26 22:41:41 -05:00
Yardian Support 7e3fc18c8c Update Yardian codeowners to @aeon-matrix (#172273) 2026-05-26 19:04:47 -05:00
J. Nick Koston b6cc5499aa Bump dbus-fast to 5.0.15 (#172298) 2026-05-26 19:00:28 -05:00
Manu 11920b82fe Fix typo in System Bridge (#172294)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 01:58:34 +02:00
tronikos 2649504dfb Fix hardcoded exception string in opower (#172295) 2026-05-27 01:29:31 +02:00
J. Nick Koston 0a7293dbbd Bump qingping-ble to 1.1.4 and update CGPR1 test fixtures (#172292) 2026-05-26 18:21:23 -05:00
J. Nick Koston 057788d531 Add composite action to cache CI apt installs (#171735) 2026-05-27 01:17:31 +02:00
J. Nick Koston 74cb4e2448 Bump aioesphomeapi to 45.3.1 (#172287) 2026-05-26 18:10:36 -05:00
Manu 62aa79a304 Add delete profile/header picture to mastodon.update_profile action (#170930)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-27 01:09:19 +02:00
A. Gideonse da74ae1955 Add Rated Capacity to Indevolt Gen-1 devices (#171107) 2026-05-27 01:09:02 +02:00
Maikel Punie 2a4728463b Fix swallowed exceptions in velbus action handlers (#171111) 2026-05-27 01:08:42 +02:00
Amit Finkelstein 3c5bcad0e9 Update Jewish calendar holiday at candle lighting and Havdalah (#170357) 2026-05-27 01:08:22 +02:00
Adam Katic 2388353bd2 Add diagnostics support for cert_expiry integration (#170767)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 01:01:54 +02:00
Erik Montnemery 98823d6816 Use select selector for input of cast uuid allow list and CEC ignore list (#171201) 2026-05-27 00:55:49 +02:00
Thomas D cdd09f2535 Remove redundant async_on_unload calls in Qbus integration (#171214) 2026-05-27 00:55:24 +02:00
Glenn Waters 2c900c59eb ElkM1 integration: add switch_output_turn_on_for action (#170128)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 00:51:47 +02:00
Thomas55555 68757996de Add google air quality forecast service (#171142)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 00:48:53 +02:00
renovate[bot] 0fa3985b1d Update infrared-protocols to 5.6.1 (#172289) 2026-05-26 23:48:18 +01:00
jameson_uk a2551647b8 Add media_player platform to Alexa Devices (#165825)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
2026-05-27 00:43:04 +02:00
Michael e19601f991 Remove deprecated APCUPSD sensors (#172280) 2026-05-27 00:17:42 +02:00
Michael bc6060f98b Remove deprecated call_in_progress binary-sensor in VoIP (#172285) 2026-05-26 23:40:50 +02:00
A. Gideonse 0e2190fb25 Add battery cycles to Indevolt (#172286) 2026-05-26 23:40:32 +02:00
Crocmagnon dd75a39e25 data grand lyon: update quality scale (#170311) 2026-05-26 23:39:48 +02:00
Heikki Henriksen 6efb3fffa3 prusalink: extract press_button_and_verify fixture for button tests (#170332)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:29:54 +02:00
epenet 4ef409f3cd Store login_token in renault config-flow (#171707) 2026-05-26 23:29:23 +02:00
Paulus Schoutsen 0842c1cdfc Add LG TV via Serial integration (#170945)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-26 23:27:01 +02:00
Joost Lekkerkerker 49c045236c Enable N806 (#171388)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-05-26 23:23:39 +02:00
AlCalzone 0b687df9f8 Migrate opensensemap to UI configuration (#171066)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-26 23:22:57 +02:00
Miko Stern ffcab49087 Improve Israel Rail departure sensor coverage (#171397)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-26 23:09:39 +02:00
Retha Runolfsson 06c92cd328 Add half lock for switchbot lock ultra (#168750) 2026-05-26 23:07:30 +02:00
HoffmanEl 66d4124439 Add quality scale cert expiry (#170491)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-26 22:42:38 +02:00
Copilot 99877d79e3 Replace duplicated ATTR_LOCATION with shared homeassistant.const import in hassio and remove unused ATTR_STATE mapping (#171334)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: agners <34061+agners@users.noreply.github.com>
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-05-26 22:42:02 +02:00
Thomas D 978171b600 Use reported units for the Qbus integration (#171588) 2026-05-26 22:41:45 +02:00
Jonathan Segev 4bd011702e Add room priority select entity to Lyric integration (#167942) 2026-05-26 22:40:14 +02:00
Crocmagnon 64bc689bcf add ovhcloud_ai_endpoints integration (#171402) 2026-05-26 22:38:18 +02:00
197 changed files with 8399 additions and 2039 deletions
@@ -0,0 +1,52 @@
name: Cache and install APT packages
description: >-
Wraps awalsh128/cache-apt-pkgs-action with the workarounds Home Assistant CI
needs. Removes the conflicting Microsoft apt source before any apt run, and
points the dynamic linker at the host's multiarch lib subdirectories so
shared libraries that rely on update-alternatives or postinst-managed paths
(eg libblas, liblapack pulled in by ffmpeg) stay reachable since the upstream
action does not execute postinst scripts on cache restore.
inputs:
packages:
description: Space-delimited list of apt packages to install.
required: true
version:
description: Cache version. Bump to invalidate the cache.
required: false
default: "1"
execute_install_scripts:
description: >-
Pass-through to awalsh128/cache-apt-pkgs-action. Postinst scripts are not
actually cached by the upstream action, so this is largely a no-op today.
required: false
default: "false"
runs:
using: composite
steps:
- name: Remove conflicting Microsoft apt source
shell: bash
run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
- name: Install apt packages via cache
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
with:
packages: ${{ inputs.packages }}
version: ${{ inputs.version }}
execute_install_scripts: ${{ inputs.execute_install_scripts }}
- name: Refresh dynamic linker cache
shell: bash
run: |
# awalsh128/cache-apt-pkgs-action does not run postinst scripts on
# cache restore, so update-alternatives symlinks (eg the one libblas
# creates at /usr/lib/<multiarch>/libblas.so.3) are never produced.
# Add every /usr/lib/<multiarch> subdirectory that holds shared
# libraries to the ldconfig search path so the dynamic linker still
# finds them. Use dpkg-architecture to derive the host's multiarch
# tuple so this works on non-x86_64 runners too.
multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
find "/usr/lib/${multiarch}" -mindepth 2 -maxdepth 2 \
-name '*.so.*' -printf '%h\n' \
| sort -u \
| sudo tee /etc/ld.so.conf.d/zzz-cache-apt-extras.conf > /dev/null
sudo ldconfig
+7 -7
View File
@@ -36,7 +36,7 @@
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
# - github/gh-aw-actions/setup@efa55847f72aadb03490d955263ff911bf758700 # v0.74.8
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
@@ -90,7 +90,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
uses: github/gh-aw-actions/setup@efa55847f72aadb03490d955263ff911bf758700 # v0.74.8
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -352,7 +352,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
uses: github/gh-aw-actions/setup@efa55847f72aadb03490d955263ff911bf758700 # v0.74.8
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -961,7 +961,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
uses: github/gh-aw-actions/setup@efa55847f72aadb03490d955263ff911bf758700 # v0.74.8
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1100,7 +1100,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
uses: github/gh-aw-actions/setup@efa55847f72aadb03490d955263ff911bf758700 # v0.74.8
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1325,7 +1325,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
uses: github/gh-aw-actions/setup@efa55847f72aadb03490d955263ff911bf758700 # v0.74.8
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1383,7 +1383,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
uses: github/gh-aw-actions/setup@efa55847f72aadb03490d955263ff911bf758700 # v0.74.8
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+97 -204
View File
@@ -60,9 +60,7 @@ env:
# - 15.2 is the latest (as of 9 Feb 2023)
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
UV_CACHE_DIR: /tmp/uv-cache
APT_CACHE_BASE: /home/runner/work/apt
APT_CACHE_DIR: /home/runner/work/apt/cache
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
APT_CACHE_VERSION: 1
SQLALCHEMY_WARN_20: 1
PYTHONASYNCIODEBUG: 1
HASS_CI: 1
@@ -86,7 +84,6 @@ jobs:
core: ${{ steps.core.outputs.changes }}
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
integrations: ${{ steps.integrations.outputs.changes }}
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
requirements: ${{ steps.core.outputs.requirements }}
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
@@ -116,10 +113,6 @@ jobs:
# Include HA_SHORT_VERSION to force the immediate creation
# of a new uv cache entry after a version bump.
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
- name: Generate partial apt restore key
id: generate_apt_cache_key
run: |
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
- name: Filter for core changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: core
@@ -384,65 +377,36 @@ jobs:
path: ${{ env.UV_CACHE_DIR }}
key: ${{ steps.generate-uv-key.outputs.full_key }}
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
- name: Check if apt cache exists
id: cache-apt-check
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
if: |
steps.cache-venv.outputs.cache-hit != 'true'
|| steps.cache-apt-check.outputs.cache-hit != 'true'
id: install-os-deps
if: steps.cache-venv.outputs.cache-hit != 'true'
timeout-minutes: 10
env:
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
mkdir -p ${APT_CACHE_DIR}
mkdir -p ${APT_LIST_CACHE_DIR}
fi
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils \
libavcodec-dev \
libavdevice-dev \
libavfilter-dev \
libavformat-dev \
libavutil-dev \
libswresample-dev \
libswscale-dev \
libudev-dev
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
sudo chmod -R 755 ${APT_CACHE_BASE}
fi
- name: Save apt cache
if: |
always()
&& steps.cache-apt-check.outputs.cache-hit != 'true'
&& steps.install-os-deps.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: ./.github/actions/cache-apt-packages
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
libavcodec-dev
libavdevice-dev
libavfilter-dev
libavformat-dev
libavutil-dev
libswresample-dev
libswscale-dev
libudev-dev
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Read uv version from requirements.txt
if: steps.cache-venv.outputs.cache-hit != 'true'
id: read-uv-version
run: |
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
- name: Set up uv
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ steps.read-uv-version.outputs.version }}
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
id: create-venv
@@ -450,8 +414,6 @@ jobs:
python -m venv venv
. venv/bin/activate
python --version
pip install "$(grep '^uv' < requirements.txt)"
uv pip install -U "pip>=25.2"
uv pip install -r requirements.txt
uv pip install -r requirements_all.txt -r requirements_test.txt
uv pip install -e . --config-settings editable_mode=compat
@@ -506,30 +468,16 @@ jobs:
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
libturbojpeg
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: libturbojpeg
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -876,32 +824,20 @@ jobs:
- info
- base
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -952,33 +888,21 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -1105,34 +1029,22 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libmariadb-dev-compat \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libmariadb-dev-compat
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -1266,36 +1178,29 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get -y install \
postgresql-server-dev-14
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up PostgreSQL apt repository
run: sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
- name: Cache PostgreSQL development headers
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: postgresql-server-dev-14
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -1449,33 +1354,21 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+2
View File
@@ -337,6 +337,7 @@ homeassistant.components.led_ble.*
homeassistant.components.lektrico.*
homeassistant.components.letpot.*
homeassistant.components.lg_infrared.*
homeassistant.components.lg_tv_rs232.*
homeassistant.components.libre_hardware_monitor.*
homeassistant.components.lidarr.*
homeassistant.components.liebherr.*
@@ -428,6 +429,7 @@ homeassistant.components.otp.*
homeassistant.components.ouman_eh_800.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.ovhcloud_ai_endpoints.*
homeassistant.components.p1_monitor.*
homeassistant.components.paj_gps.*
homeassistant.components.panel_custom.*
Generated
+8 -2
View File
@@ -987,6 +987,8 @@ CLAUDE.md @home-assistant/core
/tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
/tests/components/lg_thinq/ @LG-ThinQ-Integration
/homeassistant/components/lg_tv_rs232/ @balloob
/tests/components/lg_tv_rs232/ @balloob
/homeassistant/components/libre_hardware_monitor/ @Sab44
/tests/components/libre_hardware_monitor/ @Sab44
/homeassistant/components/lichess/ @aryanhasgithub
@@ -1290,6 +1292,8 @@ CLAUDE.md @home-assistant/core
/tests/components/openhome/ @bazwilliams
/homeassistant/components/openrgb/ @felipecrs
/tests/components/openrgb/ @felipecrs
/homeassistant/components/opensensemap/ @AlCalzone
/tests/components/opensensemap/ @AlCalzone
/homeassistant/components/opensky/ @joostlek
/tests/components/opensky/ @joostlek
/homeassistant/components/opentherm_gw/ @mvn23
@@ -1317,6 +1321,8 @@ CLAUDE.md @home-assistant/core
/tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek @AmGarera
/tests/components/overseerr/ @joostlek @AmGarera
/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon
/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -2048,8 +2054,8 @@ CLAUDE.md @home-assistant/core
/tests/components/yamaha_musiccast/ @vigonotion @micha91
/homeassistant/components/yandex_transport/ @rishatik92 @devbis
/tests/components/yandex_transport/ @rishatik92 @devbis
/homeassistant/components/yardian/ @h3l1o5
/tests/components/yardian/ @h3l1o5
/homeassistant/components/yardian/ @aeon-matrix
/tests/components/yardian/ @aeon-matrix
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/homeassistant/components/yeelightsunflower/ @lindsaymarkward
@@ -17,6 +17,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.EVENT,
Platform.MEDIA_PLAYER,
Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
@@ -40,6 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
await coordinator.async_config_entry_first_refresh()
await coordinator.sync_history_state()
await coordinator.sync_media_state()
async def _on_http2_reauth_required() -> None:
entry.async_start_reauth(hass)
@@ -8,7 +8,12 @@ from aioamazondevices.exceptions import (
CannotConnect,
CannotRetrieveData,
)
from aioamazondevices.structures import AmazonDevice, AmazonVocalRecord
from aioamazondevices.structures import (
AmazonDevice,
AmazonMediaState,
AmazonVocalRecord,
AmazonVolumeState,
)
from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
@@ -74,10 +79,17 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
}
self._vocal_records: dict[str, AmazonVocalRecord] = {}
self.api.on_history_event.append(self.history_state_event_handler)
self.api.on_history_event.freeze()
self._volume_states: dict[str, AmazonVolumeState] = {}
self.api.on_volume_state_event.append(self.volume_state_event_handler)
self.api.on_volume_state_event.freeze()
self._media_states: dict[str, AmazonMediaState] = {}
self.api.on_media_state_event.append(self.media_state_event_handler)
self.api.on_media_state_event.freeze()
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""
try:
@@ -189,3 +201,31 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
def vocal_records(self) -> dict[str, AmazonVocalRecord]:
"""Vocal records of devices."""
return self._vocal_records
async def sync_media_state(self) -> None:
"""Sync media state."""
await self.api.sync_media_state()
async def media_state_event_handler(
self, media_state: dict[str, AmazonMediaState]
) -> None:
"""Handle pushed media state changed events."""
self._media_states = media_state
self.async_update_listeners()
@property
def media_states(self) -> dict[str, AmazonMediaState]:
"""Media state of devices."""
return self._media_states
async def volume_state_event_handler(
self, volume_states: dict[str, AmazonVolumeState]
) -> None:
"""Handle pushed volume change events."""
self._volume_states = volume_states
self.async_update_listeners()
@property
def volume_states(self) -> dict[str, AmazonVolumeState]:
"""Volumes of devices."""
return self._volume_states
@@ -0,0 +1,294 @@
"""Media player platform for Alexa Devices."""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Final
from aioamazondevices.structures import (
AmazonMediaControls,
AmazonMediaState,
AmazonVolumeState,
)
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityDescription,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
STANDARD_SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PLAY_MEDIA
)
@dataclass(frozen=True, kw_only=True)
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
"""Describes an Alexa Devices media player entity."""
MEDIA_PLAYERS: Final = (
AmazonDevicesMediaPlayerEntityDescription(
key="media",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices media player entities from a config entry."""
coordinator = entry.runtime_data
known_devices: set[str] = set()
def _check_device() -> None:
"""Add entities for newly discovered devices."""
new_entities: list[AlexaDevicesMediaPlayer] = []
for serial_num, device in coordinator.data.items():
if serial_num in known_devices or not device.media_player_supported:
continue
known_devices.add(serial_num)
new_entities.extend(
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
for description in MEDIA_PLAYERS
)
if new_entities:
async_add_entities(new_entities)
remove_listener = coordinator.async_add_listener(_check_device)
entry.async_on_unload(remove_listener)
_check_device()
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
"""Representation of an Alexa device media player."""
entity_description: AmazonDevicesMediaPlayerEntityDescription
_attr_name = None # Uses the device name
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_volume_step = 0.05
def __init__(
self,
coordinator: AmazonDevicesCoordinator,
serial_num: str,
description: AmazonDevicesMediaPlayerEntityDescription,
) -> None:
"""Initialize."""
self._prev_volume: int | None = None
super().__init__(coordinator, serial_num, description)
@property
def media_state(self) -> AmazonMediaState | None:
"""Return the media state relating to device."""
if not self.coordinator or not self.coordinator.media_states:
return None
return self.coordinator.media_states.get(self._serial_num)
@property
def volume_state(self) -> AmazonVolumeState | None:
"""Volume settings for device."""
if not self.coordinator or not self.coordinator.volume_states:
return None
return self.coordinator.volume_states.get(self._serial_num)
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Return dynamically supported features based on current media."""
features = STANDARD_SUPPORTED_FEATURES
if self.media_state is None:
return features
if self.media_state.pause_enabled:
features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE
if self.media_state.next_enabled:
features |= MediaPlayerEntityFeature.NEXT_TRACK
if self.media_state.previous_enabled:
features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
return features
@property
def state(self) -> MediaPlayerState | None:
"""Return the current state of the player."""
if not self.media_state:
return MediaPlayerState.IDLE
if self.media_state.player_state == "PLAYING":
return MediaPlayerState.PLAYING
if self.media_state.player_state == "PAUSED":
return MediaPlayerState.PAUSED
return MediaPlayerState.IDLE
@property
def volume_level(self) -> float | None:
"""Return the volume level (0.0 to 1.0)."""
if not self.volume_state or self.volume_state.volume is None:
return None
return self.volume_state.volume / 100
@property
def is_volume_muted(self) -> bool | None:
"""Return True if the volume is muted."""
if not self.volume_state:
return None
return self.volume_state.volume == 0
@property
def media_title(self) -> str | None:
"""Track title."""
if not self.media_state:
return None
return self.media_state.now_playing_title
@property
def media_artist(self) -> str | None:
"""Artist name."""
if not self.media_state:
return None
return self.media_state.now_playing_line1
@property
def media_album_name(self) -> str | None:
"""Album name."""
if not self.media_state:
return None
return self.media_state.now_playing_line2
@property
def media_image_url(self) -> str | None:
"""Album art URL."""
if not self.media_state:
return None
return self.media_state.now_playing_url
@property
def media_duration(self) -> int | None:
"""Duration in seconds."""
if not self.media_state:
return None
return self.media_state.media_length
@property
def media_position(self) -> int | None:
"""Current playback position in seconds."""
if not self.media_state:
return None
return self.media_state.media_position
@property
def media_position_updated_at(self) -> datetime | None:
"""When media_position was last updated — HA uses this to interpolate the progress bar."""
if not self.media_state:
return None
return self.media_state.media_position_updated_at
@property
def media_content_type(self) -> MediaType | None:
"""Content type — tells HA what kind of media is playing."""
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
return MediaType.MUSIC
return None
async def async_play_media(
self,
media_type: MediaType | str,
media_id: str,
enqueue: MediaPlayerEnqueue | None = None,
announce: bool | None = None,
**kwargs: Any,
) -> None:
"""Play a piece of media."""
await self.async_call_alexa_music(media_id, media_type)
@alexa_api_call
async def async_call_alexa_music(
self, search_phrase: str, provider_id: str
) -> None:
"""Call alexa music."""
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
@alexa_api_call
async def async_set_device_volume(self, volume: int) -> None:
"""Set the device volume."""
_LOGGER.debug(
"Setting volume for %s to %s%%",
self.device.serial_number,
volume,
)
await self.coordinator.api.set_device_volume(self.device, volume)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level (0.0 to 1.0)."""
device_volume = round(volume * 100)
await self.async_set_device_volume(device_volume)
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or un-mute the volume."""
# Whilst you can mute a device by asking it there appears to be
# no way to do this programmatically so set volume to 0
if not self.volume_state or self.volume_state.volume is None:
return
if mute:
self._prev_volume = self.volume_state.volume
target_volume = 0
else:
if self._prev_volume is None:
return
target_volume = self._prev_volume
await self.async_set_volume_level(target_volume / 100)
@alexa_api_call
async def _send_media_command(self, command: AmazonMediaControls) -> None:
_LOGGER.debug(
"Sending media command '%s' to %s", command, self.device.serial_number
)
await self.coordinator.api.send_media_command(self.device, command)
async def async_media_stop(self) -> None:
"""Send stop command."""
await self._send_media_command(AmazonMediaControls.Stop)
async def async_media_pause(self) -> None:
"""Send pause command."""
await self._send_media_command(AmazonMediaControls.Pause)
async def async_media_play(self) -> None:
"""Send play command."""
await self._send_media_command(AmazonMediaControls.Play)
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self._send_media_command(AmazonMediaControls.Next)
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self._send_media_command(AmazonMediaControls.Previous)
@@ -230,13 +230,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
if entry.version == 2 and entry.minor_version == 3:
# Remove Temperature parameter
CONF_TEMPERATURE = "temperature"
temperature_key = "temperature"
for subentry in entry.subentries.values():
data = subentry.data.copy()
if CONF_TEMPERATURE not in data:
if temperature_key not in data:
continue
data.pop(CONF_TEMPERATURE, None)
data.pop(temperature_key, None)
hass.config_entries.async_update_subentry(entry, subentry, data=data)
hass.config_entries.async_update_entry(entry, minor_version=4)
-24
View File
@@ -7,27 +7,3 @@ CONNECTION_TIMEOUT: int = 10
# Field name of last self test retrieved from apcupsd.
LAST_S_TEST: Final = "laststest"
# Mapping of deprecated sensor keys (as reported by apcupsd,
# lower-cased) to their deprecation
# repair issue translation keys.
DEPRECATED_SENSORS: Final = {
"apc": "apc_deprecated",
"end apc": "date_deprecated",
"date": "date_deprecated",
"apcmodel": "available_via_device_info",
"model": "available_via_device_info",
"firmware": "available_via_device_info",
"version": "available_via_device_info",
"upsname": "available_via_device_info",
"serialno": "available_via_device_info",
}
AVAILABLE_VIA_DEVICE_ATTR: Final = {
"apcmodel": "model",
"model": "model",
"firmware": "hw_version",
"version": "sw_version",
"upsname": "name",
"serialno": "serial_number",
}
+19 -121
View File
@@ -1,11 +1,10 @@
"""Support for APCUPSd sensors."""
import logging
from typing import Final
import dateutil
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -24,11 +23,9 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.helpers.issue_registry as ir
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
from .const import LAST_S_TEST
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
from .entity import APCUPSdEntity
@@ -36,6 +33,20 @@ PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
# List of useless sensors to ignore, since they are either provided in device
# information, or not useful at all
IGNORED_SENSORS: Final = {
"apc",
"end apc",
"date",
"apcmodel",
"model",
"firmware",
"version",
"upsname",
"serialno",
}
SENSORS: dict[str, SensorEntityDescription] = {
"alarmdel": SensorEntityDescription(
key="alarmdel",
@@ -49,18 +60,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
"apc": SensorEntityDescription(
key="apc",
translation_key="apc_status",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"apcmodel": SensorEntityDescription(
key="apcmodel",
translation_key="apc_model",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"badbatts": SensorEntityDescription(
key="badbatts",
translation_key="bad_batteries",
@@ -100,12 +99,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.DURATION,
),
"date": SensorEntityDescription(
key="date",
translation_key="date",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"dipsw": SensorEntityDescription(
key="dipsw",
translation_key="dip_switch_settings",
@@ -132,23 +125,11 @@ SENSORS: dict[str, SensorEntityDescription] = {
translation_key="wake_delay",
entity_category=EntityCategory.DIAGNOSTIC,
),
"end apc": SensorEntityDescription(
key="end apc",
translation_key="date_and_time",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"extbatts": SensorEntityDescription(
key="extbatts",
translation_key="external_batteries",
entity_category=EntityCategory.DIAGNOSTIC,
),
"firmware": SensorEntityDescription(
key="firmware",
translation_key="firmware_version",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"hitrans": SensorEntityDescription(
key="hitrans",
translation_key="transfer_high",
@@ -264,12 +245,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
translation_key="min_time",
entity_category=EntityCategory.DIAGNOSTIC,
),
"model": SensorEntityDescription(
key="model",
translation_key="model",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"nombattv": SensorEntityDescription(
key="nombattv",
translation_key="battery_nominal_voltage",
@@ -358,12 +333,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"serialno": SensorEntityDescription(
key="serialno",
translation_key="serial_number",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"starttime": SensorEntityDescription(
key="starttime",
translation_key="startup_time",
@@ -404,18 +373,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
translation_key="ups_mode",
entity_category=EntityCategory.DIAGNOSTIC,
),
"upsname": SensorEntityDescription(
key="upsname",
translation_key="ups_name",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"version": SensorEntityDescription(
key="version",
translation_key="version",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"xoffbat": SensorEntityDescription(
key="xoffbat",
translation_key="transfer_from_battery",
@@ -481,9 +438,10 @@ async def async_setup_entry(
# as unknown initially.
#
# We also sort the resources to ensure the order of entities
# created is deterministic since "APCMODEL" and "MODEL"
# resources map to the same "Model" name.
# created is deterministic
for resource in sorted(available_resources | {LAST_S_TEST}):
if resource in IGNORED_SENSORS:
continue
if resource not in SENSORS:
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
continue
@@ -561,63 +519,3 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
self._attr_native_value, inferred_unit = infer_unit(data)
if not self.native_unit_of_measurement:
self._attr_native_unit_of_measurement = inferred_unit
async def async_added_to_hass(self) -> None:
"""Handle when entity is added to Home Assistant.
If this is a deprecated sensor entity, create a repair issue to guide
the user to disable it.
"""
await super().async_added_to_hass()
if not self.enabled:
return
reason = DEPRECATED_SENSORS.get(self.entity_description.key)
if not reason:
return
automations = automations_with_entity(self.hass, self.entity_id)
scripts = scripts_with_entity(self.hass, self.entity_id)
if not automations and not scripts:
return
entity_registry = er.async_get(self.hass)
items = [
f"- [{entry.name or entry.original_name or entity_id}]"
f"(/config/{integration}/edit/"
f"{entry.unique_id or entity_id.split('.', 1)[-1]})"
for integration, entities in (
("automation", automations),
("script", scripts),
)
for entity_id in entities
if (entry := entity_registry.async_get(entity_id))
]
placeholders = {
"entity_name": str(self.name or self.entity_id),
"entity_id": self.entity_id,
"items": "\n".join(items),
}
if via_attr := AVAILABLE_VIA_DEVICE_ATTR.get(self.entity_description.key):
placeholders["available_via_device_attr"] = via_attr
if device_entry := self.device_entry:
placeholders["device_id"] = device_entry.id
ir.async_create_issue(
self.hass,
DOMAIN,
f"{reason}_{self.entity_id}",
breaks_in_ha_version="2026.6.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=reason,
translation_placeholders=placeholders,
)
async def async_will_remove_from_hass(self) -> None:
"""Handle when entity will be removed from Home Assistant."""
await super().async_will_remove_from_hass()
if issue_key := DEPRECATED_SENSORS.get(self.entity_description.key):
ir.async_delete_issue(self.hass, DOMAIN, f"{issue_key}_{self.entity_id}")
@@ -241,19 +241,5 @@
"cannot_connect": {
"message": "Cannot connect to APC UPS Daemon."
}
},
"issues": {
"apc_deprecated": {
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because it exposes internal details of the APC UPS Daemon response.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use supported APC UPS entities instead. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
"title": "{entity_name} sensor is deprecated"
},
"available_via_device_info": {
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the same value is available from the device registry via `device_attr(\"{device_id}\", \"{available_via_device_attr}\")`.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use the `device_attr` helper instead of this sensor. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
"title": "{entity_name} sensor is deprecated"
},
"date_deprecated": {
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the timestamp is already available from other APC UPS sensors via their last updated time.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to reference any entity's `last_updated` attribute instead (for example, `states.binary_sensor.apcups_online_status.last_updated`). Reload the APC UPS Daemon integration afterwards to resolve this issue.",
"title": "{entity_name} sensor is deprecated"
}
}
}
+14 -14
View File
@@ -49,6 +49,20 @@ SENSORS_TYPE_COUNT = "sensors_count"
_LOGGER = logging.getLogger(__name__)
_ENTITY_MIGRATION_ID = {
"sensor_connected_device": "Devices Connected",
"sensor_rx_bytes": "Download",
"sensor_tx_bytes": "Upload",
"sensor_rx_rates": "Download Speed",
"sensor_tx_rates": "Upload Speed",
"sensor_load_avg1": "Load Avg (1m)",
"sensor_load_avg5": "Load Avg (5m)",
"sensor_load_avg15": "Load Avg (15m)",
"2.4GHz": "2.4GHz Temperature",
"5.0GHz": "5GHz Temperature",
"CPU": "CPU Temperature",
}
class AsusWrtSensorDataHandler:
"""Data handler for AsusWrt sensor."""
@@ -187,20 +201,6 @@ class AsusWrtRouter:
def _migrate_entities_unique_id(self) -> None:
"""Migrate router entities to new unique id format."""
_ENTITY_MIGRATION_ID = {
"sensor_connected_device": "Devices Connected",
"sensor_rx_bytes": "Download",
"sensor_tx_bytes": "Upload",
"sensor_rx_rates": "Download Speed",
"sensor_tx_rates": "Upload Speed",
"sensor_load_avg1": "Load Avg (1m)",
"sensor_load_avg5": "Load Avg (5m)",
"sensor_load_avg15": "Load Avg (15m)",
"2.4GHz": "2.4GHz Temperature",
"5.0GHz": "5GHz Temperature",
"CPU": "CPU Temperature",
}
entity_reg = er.async_get(self.hass)
router_entries = er.async_entries_for_config_entry(
entity_reg, self._entry.entry_id
@@ -51,7 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
translation_key="invalid_bucket_name",
) from err
except ValueError as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_endpoint_url",
+4 -1
View File
@@ -7,7 +7,7 @@
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
"invalid_endpoint_url": "[%key:component::aws_s3::exceptions::invalid_endpoint_url::message%]"
},
"step": {
"user": {
@@ -48,6 +48,9 @@
},
"invalid_credentials": {
"message": "Bucket cannot be accessed using provided combination of access key ID and secret access key."
},
"invalid_endpoint_url": {
"message": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
}
}
}
@@ -20,7 +20,7 @@
"bluetooth-adapters==2.3.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.14",
"habluetooth==6.7.4"
"dbus-fast==5.0.15",
"habluetooth==6.7.9"
]
}
+14 -21
View File
@@ -32,8 +32,16 @@ OPTIONS_SCHEMA = KNOWN_HOSTS_SCHEMA.extend(
vol.Required(CONF_MORE_OPTIONS): section(
vol.Schema(
{
vol.Optional(CONF_UUID): str,
vol.Optional(CONF_IGNORE_CEC): str,
vol.Optional(CONF_UUID): SelectSelector(
SelectSelectorConfig(
custom_value=True, options=[], multiple=True
),
),
vol.Optional(CONF_IGNORE_CEC): SelectSelector(
SelectSelectorConfig(
custom_value=True, options=[], multiple=True
),
),
}
),
SectionConfig(collapsed=True),
@@ -109,13 +117,11 @@ class CastOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the Google Cast options."""
if user_input is not None:
ignore_cec = _string_to_list(
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, "")
ignore_cec = _trim_items(
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, [])
)
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
wanted_uuid = _string_to_list(
user_input[CONF_MORE_OPTIONS].get(CONF_UUID, "")
)
wanted_uuid = _trim_items(user_input[CONF_MORE_OPTIONS].get(CONF_UUID, []))
updated_config = dict(self.config_entry.data)
updated_config[CONF_IGNORE_CEC] = ignore_cec
updated_config[CONF_KNOWN_HOSTS] = known_hosts
@@ -132,9 +138,7 @@ class CastOptionsFlowHandler(OptionsFlow):
for key in (CONF_UUID, CONF_IGNORE_CEC):
if key not in self.config_entry.data:
continue
suggested[CONF_MORE_OPTIONS][key] = _list_to_string(
self.config_entry.data[key]
)
suggested[CONF_MORE_OPTIONS][key] = self.config_entry.data[key]
return self.async_show_form(
step_id="init",
@@ -143,16 +147,5 @@ class CastOptionsFlowHandler(OptionsFlow):
)
def _list_to_string(items: list[str]) -> str:
comma_separated_string = ""
if items:
comma_separated_string = ",".join(items)
return comma_separated_string
def _string_to_list(string: str) -> list[str]:
return [x.strip() for x in string.split(",") if x.strip()]
def _trim_items(items: list[str]) -> list[str]:
return [x.strip() for x in items if x.strip()]
@@ -0,0 +1,57 @@
"""Diagnostics for the cert_expiry integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from .coordinator import CertExpiryConfigEntry
TO_REDACT = {CONF_HOST, "name", "title", "unique_id"}
async def async_get_config_entry_diagnostics(
_hass: HomeAssistant,
entry: CertExpiryConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
entry_diagnostics = entry.as_dict()
coordinator = getattr(entry, "runtime_data", None)
coordinator_diagnostics: dict[str, Any] = {
"host": None,
"port": None,
"name": None,
"expiry_datetime": None,
"is_cert_valid": None,
"cert_error": None,
"last_update_success": None,
}
if coordinator is not None:
expiry = coordinator.data.isoformat() if coordinator.data else None
cert_error = (
(
f"{type(coordinator.cert_error).__module__}."
f"{type(coordinator.cert_error).__qualname__}"
)
if coordinator.cert_error
else None
)
coordinator_diagnostics = {
"host": coordinator.host,
"port": coordinator.port,
"name": coordinator.name,
"expiry_datetime": expiry,
"is_cert_valid": coordinator.is_cert_valid,
"cert_error": cert_error,
"last_update_success": coordinator.last_update_success,
}
return {
"entry": async_redact_data(entry_diagnostics, TO_REDACT),
"coordinator": async_redact_data(coordinator_diagnostics, TO_REDACT),
}
@@ -0,0 +1,81 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling:
status: done
comment: Certificates are checked every 12 hours via DataUpdateCoordinator.
brands: done
common-modules: done
config-flow-test-coverage:
status: done
comment: test_abort_on_socket_failed can be parametrized and should end in CREATE_ENTRY to test flow recovery.
config-flow: done
dependency-transparency:
status: exempt
comment: Integration has no external library dependencies.
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: todo
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: Config flow only collects host/port; the integration does not authenticate.
test-coverage:
status: todo
comment: Consider creating a mock_config_entry fixture and use that throughout tests.
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Integration supports a single device per config entry.
entity-category:
status: todo
comment: Extra state attributes (is_valid, error) should be moved to separate entities in the future.
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices:
status: exempt
comment: Integration supports a single device per config entry.
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -16,6 +16,10 @@
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the server to monitor.",
"port": "The port to connect to on the server."
},
"title": "Reconfigure the certificate to test"
},
"user": {
@@ -24,6 +28,10 @@
"name": "The name of the certificate",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the server to monitor.",
"port": "The port to connect to on the server."
},
"title": "Define the certificate to test"
}
}
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": ["data-grand-lyon-ha==0.7.0"]
}
@@ -49,13 +49,15 @@ rules:
status: exempt
comment: This is a service integration; there are no discoverable devices.
docs-data-update: done
docs-examples: todo
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
dynamic-devices:
status: exempt
comment: This is a service integration; devices are added and removed manually by the user.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -66,7 +68,9 @@ rules:
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
stale-devices: done
stale-devices:
status: exempt
comment: This is a service integration; devices are added and removed manually by the user.
# Platinum
async-dependency: done
@@ -38,6 +38,9 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
async_create_platform_config_not_supported_issue,
)
from homeassistant.helpers.event import (
async_track_time_interval,
async_track_utc_time_change,
@@ -379,8 +382,8 @@ async def async_extract_config(
if platform.type == PLATFORM_TYPE_LEGACY:
legacy.append(platform)
else:
raise ValueError(
f"Unable to determine type for {platform.name}: {platform.type}"
async_create_platform_config_not_supported_issue(
hass, platform.name, DOMAIN
)
return legacy
+5 -5
View File
@@ -41,7 +41,7 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
self.stations = {}
for station in stations:
label = station["label"]
rloId = station["RLOIid"]
rlo_id = station["RLOIid"]
# API annoyingly sometimes returns a list and some times returns a string
# E.g. L3121 has a label of ['Scurf Dyke', 'Scurf Dyke Dyke Level']
@@ -50,11 +50,11 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
# Similar for RLOIid
# E.g. 0018 has an RLOIid of ['10427', '9154']
if isinstance(rloId, list):
rloId = rloId[-1]
if isinstance(rlo_id, list):
rlo_id = rlo_id[-1]
fullName = label + " - " + rloId
self.stations[fullName] = station["stationReference"]
full_name = label + " - " + rlo_id
self.stations[full_name] = station["stationReference"]
if not self.stations:
return self.async_abort(reason="no_stations")
+1
View File
@@ -40,6 +40,7 @@ ELK_ELEMENTS = {
EVENT_ELKM1_KEYPAD_KEY_PRESSED = "elkm1.keypad_key_pressed"
ATTR_DURATION = "duration"
ATTR_KEYPAD_ID = "keypad_id"
ATTR_KEY = "key"
ATTR_KEY_NAME = "key_name"
@@ -48,6 +48,9 @@
},
"speak_word": {
"service": "mdi:message-minus"
},
"switch_output_turn_on_for": {
"service": "mdi:timer"
}
}
}
@@ -161,3 +161,15 @@ sensor_zone_trigger:
entity:
integration: elkm1
domain: sensor
switch_output_turn_on_for:
target:
entity:
integration: elkm1
domain: switch
fields:
duration:
example: 42
required: true
selector:
duration:
@@ -210,6 +210,16 @@
}
},
"name": "Speak word"
},
"switch_output_turn_on_for": {
"description": "Turns on an output for a specified length of time.",
"fields": {
"duration": {
"description": "Length of time to turn the output on for.",
"name": "Duration"
}
},
"name": "Switch output turn on for"
}
}
}
+34 -1
View File
@@ -1,5 +1,7 @@
"""Support for control of ElkM1 outputs (relays)."""
from datetime import timedelta
from math import ceil
from typing import Any
from elkm1_lib.const import ThermostatMode, ThermostatSetting
@@ -7,15 +9,29 @@ from elkm1_lib.elements import Element
from elkm1_lib.elk import Elk
from elkm1_lib.outputs import Output
from elkm1_lib.thermostats import Thermostat
import voluptuous as vol
from homeassistant.components.switch import SwitchEntity
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import ElkM1ConfigEntry
from .const import ATTR_DURATION, DOMAIN
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
from .models import ELKM1Data
SERVICE_SWITCH_OUTPUT_TURN_ON_FOR = "switch_output_turn_on_for"
ELK_OUTPUT_TURN_ON_FOR_SERVICE_SCHEMA: VolDictType = {
vol.Required(ATTR_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(seconds=1), max=timedelta(seconds=65535)),
),
}
async def async_setup_entry(
hass: HomeAssistant,
@@ -32,6 +48,15 @@ async def async_setup_entry(
)
async_add_entities(entities)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SWITCH_OUTPUT_TURN_ON_FOR,
entity_domain=SWITCH_DOMAIN,
schema=ELK_OUTPUT_TURN_ON_FOR_SERVICE_SCHEMA,
func="async_switch_output_turn_on_for",
)
class ElkOutput(ElkAttachedEntity, SwitchEntity):
"""Elk output as switch."""
@@ -51,6 +76,10 @@ class ElkOutput(ElkAttachedEntity, SwitchEntity):
"""Turn off the output."""
self._element.turn_off()
async def async_switch_output_turn_on_for(self, duration: timedelta) -> None:
"""Turn on an output for specified length of time."""
self._element.turn_on(ceil(duration.total_seconds()))
class ElkThermostatEMHeat(ElkEntity, SwitchEntity):
"""Elk Thermostat emergency heat as switch."""
@@ -79,3 +108,7 @@ class ElkThermostatEMHeat(ElkEntity, SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the output."""
self._elk_set(ThermostatMode.EMERGENCY_HEAT)
async def async_switch_output_turn_on_for(self, duration: timedelta) -> None:
"""Turn on an output for specified length of time: not supported for thermostat."""
raise HomeAssistantError("supported only on ElkM1 output switch entities")
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==45.2.2",
"aioesphomeapi==45.3.1",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.9.1"
],
+3 -3
View File
@@ -124,11 +124,11 @@ async def async_setup_entry(
for camera in coordinator.data:
device_category = coordinator.data[camera].get("device_category")
supportExt = coordinator.data[camera].get("supportExt")
support_ext = coordinator.data[camera].get("supportExt")
if (
device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
and supportExt
and str(SupportExt.SupportBatteryManage.value) in supportExt
and support_ext
and str(SupportExt.SupportBatteryManage.value) in support_ext
):
entities.append(
EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE)
@@ -7,19 +7,30 @@ from google_air_quality_api.auth import Auth
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import CONF_REFERRER
from .const import CONF_REFERRER, DOMAIN
from .coordinator import (
GoogleAirQualityConfigEntry,
GoogleAirQualityRuntimeData,
GoogleAirQualityUpdateCoordinator,
)
from .services import async_setup_services
PLATFORMS: list[Platform] = [
Platform.SENSOR,
]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Google Air Quality integration."""
async_setup_services(hass)
return True
async def async_setup_entry(
hass: HomeAssistant, entry: GoogleAirQualityConfigEntry
@@ -11,5 +11,10 @@
"default": "mdi:molecule"
}
}
},
"services": {
"get_forecast": {
"service": "mdi:clock-end"
}
}
}
@@ -0,0 +1,107 @@
"""Services for the Google Air Quality integration."""
from datetime import timedelta
from typing import Final, cast
from google_air_quality_api.exceptions import GoogleAirQualityApiError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr, selector
from .const import DOMAIN
from .coordinator import GoogleAirQualityConfigEntry
ATTR_HOURS: Final = "hours"
FORECAST_HOURS_MAX: Final = 96
SERVICE_GET_FORECAST: Final = "get_forecast"
SERVICE_GET_FORECAST_SCHEMA: Final = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector({"integration": DOMAIN}),
vol.Required(ATTR_HOURS): vol.All(
vol.Coerce(int), vol.Range(min=1, max=FORECAST_HOURS_MAX)
),
}
)
def _get_config_entry_and_subentry_id(
hass: HomeAssistant, device_id: str
) -> tuple[GoogleAirQualityConfigEntry, str]:
"""Get the config entry and subentry from a selected location device."""
device = dr.async_get(hass).async_get(device_id)
if device is not None:
for entry_id, subentry_ids in device.config_entries_subentries.items():
config_entry: ConfigEntry | None = hass.config_entries.async_get_entry(
entry_id
)
if config_entry is None or config_entry.domain != DOMAIN:
continue
gaq_config_entry = cast(GoogleAirQualityConfigEntry, config_entry)
for subentry_id in subentry_ids:
if (
subentry_id is not None
and subentry_id
in gaq_config_entry.runtime_data.subentries_runtime_data
):
return gaq_config_entry, subentry_id
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
)
async def _async_get_forecast(call: ServiceCall) -> ServiceResponse:
"""Fetch the air quality forecast for a configured location."""
config_entry, subentry_id = _get_config_entry_and_subentry_id(
call.hass, call.data[ATTR_DEVICE_ID]
)
coordinator = config_entry.runtime_data.subentries_runtime_data[subentry_id]
try:
forecast = await config_entry.runtime_data.api.async_get_forecast(
coordinator.lat,
coordinator.long,
timedelta(hours=call.data[ATTR_HOURS]),
)
except GoogleAirQualityApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unable_to_fetch",
) from err
return cast(
ServiceResponse,
{
"forecast_time": forecast.hourly_forecasts[0].date_time,
"indexes": forecast.hourly_forecasts[0].indexes,
"pollutants": forecast.hourly_forecasts[0].pollutants,
},
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
hass.services.async_register(
DOMAIN,
SERVICE_GET_FORECAST,
_async_get_forecast,
schema=SERVICE_GET_FORECAST_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -0,0 +1,15 @@
get_forecast:
fields:
device_id:
required: true
selector:
device:
integration: google_air_quality
hours:
required: true
selector:
number:
min: 1
max: 96
step: 1
mode: box
@@ -270,8 +270,27 @@
}
},
"exceptions": {
"device_not_found": {
"message": "Location not found."
},
"unable_to_fetch": {
"message": "[%key:component::google_air_quality::common::unable_to_fetch%]"
}
},
"services": {
"get_forecast": {
"description": "Get an air quality forecast for a configured location.",
"fields": {
"device_id": {
"description": "The location to fetch the forecast for.",
"name": "Location"
},
"hours": {
"description": "How many hours into the future to forecast.",
"name": "Hours"
}
},
"name": "Get forecast"
}
}
}
+5 -5
View File
@@ -117,13 +117,13 @@ class DriveClient:
"""Get storage quota of the current user."""
res = await self._api.get_user(params={"fields": "storageQuota"})
storageQuota = res["storageQuota"]
limit = storageQuota.get("limit")
storage_quota = res["storageQuota"]
limit = storage_quota.get("limit")
return StorageQuotaData(
limit=int(limit) if limit is not None else None,
usage=int(storageQuota.get("usage", 0)),
usage_in_drive=int(storageQuota.get("usageInDrive", 0)),
usage_in_trash=int(storageQuota.get("usageInTrash", 0)),
usage=int(storage_quota.get("usage", 0)),
usage_in_drive=int(storage_quota.get("usageInDrive", 0)),
usage_in_trash=int(storage_quota.get("usageInTrash", 0)),
)
async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]:
@@ -19,7 +19,7 @@ DEFAULT_STT_PROMPT = "Transcribe the attached audio"
CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
RECOMMENDED_CHAT_MODEL = "models/gemini-3.1-flash-lite"
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
@@ -580,17 +580,17 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
generateContentConfig = self.create_generate_content_config()
generateContentConfig.tools = tools or None
generateContentConfig.system_instruction = (
generate_content_config = self.create_generate_content_config()
generate_content_config.tools = tools or None
generate_content_config.system_instruction = (
prompt if supports_system_instruction else None
)
generateContentConfig.automatic_function_calling = (
generate_content_config.automatic_function_calling = (
AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None)
)
if structure:
generateContentConfig.response_mime_type = "application/json"
generateContentConfig.response_schema = _format_schema(
generate_content_config.response_mime_type = "application/json"
generate_content_config.response_schema = _format_schema(
convert(
structure,
custom_serializer=(
@@ -608,7 +608,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
*messages,
]
chat = self._genai_client.aio.chats.create(
model=model_name, history=messages, config=generateContentConfig
model=model_name, history=messages, config=generate_content_config
)
user_message = chat_log.content[-1]
assert isinstance(user_message, conversation.UserContent)
@@ -313,7 +313,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse:
)
coordinator = entry.runtime_data
FUNC_MAP = {
func_map = {
SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest,
SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest,
SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest,
@@ -322,7 +322,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse:
SERVICE_START_QUEST: coordinator.habitica.start_quest,
}
func = FUNC_MAP[call.service]
func = func_map[call.service]
try:
response = await func()
-17
View File
@@ -131,12 +131,8 @@ ATTR_AUTO_UPDATE = "auto_update"
ATTR_VERSION = "version"
ATTR_VERSION_LATEST = "version_latest"
ATTR_CPU_PERCENT = "cpu_percent"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCATION = "location"
ATTR_MEMORY_PERCENT = "memory_percent"
ATTR_SLUG = "slug"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_STATE = "state"
ATTR_STARTED = "started"
ATTR_URL = "url"
ATTR_REPOSITORY = "repository"
@@ -177,19 +173,6 @@ CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor"
CONTAINER_STATS = "stats"
CONTAINER_INFO = "info"
# This is a mapping of which endpoint the key in the addon data
# is obtained from so we know which endpoint to update when the
# coordinator polls for updates.
KEY_TO_UPDATE_TYPES: dict[str, set[str]] = {
ATTR_VERSION_LATEST: {CONTAINER_INFO},
ATTR_MEMORY_PERCENT: {CONTAINER_STATS},
ATTR_CPU_PERCENT: {CONTAINER_STATS},
ATTR_VERSION: {CONTAINER_INFO},
ATTR_STATE: {CONTAINER_INFO},
}
REQUEST_REFRESH_DELAY = 10
HELP_URLS = {
+1 -2
View File
@@ -15,7 +15,7 @@ from aiohasupervisor.models import (
)
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
from homeassistant.const import ATTR_DEVICE_ID, ATTR_LOCATION, ATTR_NAME
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -43,7 +43,6 @@ from .const import (
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_SLUG,
DOMAIN,
+5 -6
View File
@@ -30,6 +30,11 @@ OPEN_CLOSE_ATTRIBUTES = [
AttributeType.UP_DOWN,
]
POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
COVER_DEVICE_PROFILES = {
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
}
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
@@ -69,12 +74,6 @@ def get_cover_features(
def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
"""Determine the device class a homee node based on the node profile."""
COVER_DEVICE_PROFILES = {
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
}
return COVER_DEVICE_PROFILES.get(node.profile)
@@ -2,7 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from typing import Any, override
from incomfortclient import Heater as InComfortHeater
@@ -97,11 +97,13 @@ class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity):
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
@property
@override
def is_on(self) -> bool:
"""Return the status of the sensor."""
return bool(self._heater.status[self.entity_description.value_key])
@property
@override
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the device state attributes."""
if (attributes_fn := self.entity_description.extra_state_attributes_fn) is None:
@@ -1,6 +1,6 @@
"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway."""
from typing import Any
from typing import Any, override
from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom
@@ -76,16 +76,19 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
)
@property
@override
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
return {"status": self._room.status}
@property
@override
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._room.room_temp
@property
@override
def hvac_action(self) -> HVACAction | None:
"""Return the actual current HVAC action."""
if self._heater.is_burning and self._heater.is_pumping:
@@ -93,6 +96,7 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
return HVACAction.IDLE
@property
@override
def target_temperature(self) -> float | None:
"""Return the (override)temperature we try to reach.
@@ -106,11 +110,13 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
return self._room.setpoint
return self._room.override or self._room.setpoint
@override
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new target temperature for this zone."""
temperature: float = kwargs[ATTR_TEMPERATURE]
await self._room.set_override(temperature)
await self.coordinator.async_refresh()
@override
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@@ -2,7 +2,7 @@
from collections.abc import Mapping
import logging
from typing import Any
from typing import Any, override
from incomfortclient import InvalidGateway, InvalidHeaterList
import voluptuous as vol
@@ -100,6 +100,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
_discovered_host: str
@override
@staticmethod
@callback
def async_get_options_flow(
@@ -108,6 +109,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return InComfortOptionsFlowHandler()
@override
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
@@ -169,6 +171,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={CONF_HOST: self._discovered_host},
)
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -3,7 +3,7 @@
from dataclasses import dataclass, field
from datetime import timedelta
import logging
from typing import Any
from typing import Any, override
from aiohttp import ClientResponseError
from incomfortclient import (
@@ -74,6 +74,7 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
)
self.incomfort_data = incomfort_data
@override
async def _async_update_data(self) -> InComfortData:
"""Fetch data from API endpoint."""
try:
+3 -1
View File
@@ -1,7 +1,7 @@
"""Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway."""
from dataclasses import dataclass
from typing import Any
from typing import Any, override
from incomfortclient import Heater as InComfortHeater
@@ -104,11 +104,13 @@ class IncomfortSensor(IncomfortBoilerEntity, SensorEntity):
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
@property
@override
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return]
@property
@override
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the device state attributes."""
if (extra_key := self.entity_description.extra_key) is None:
@@ -1,7 +1,7 @@
"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway."""
import logging
from typing import Any
from typing import Any, override
from incomfortclient import Heater as InComfortHeater
@@ -49,11 +49,13 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
self._attr_unique_id = heater.serial_no
@property
@override
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS}
@property
@override
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self._heater.is_tapping:
@@ -67,6 +69,7 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
return max(self._heater.heater_temp, self._heater.tap_temp)
@property
@override
def current_operation(self) -> str | None:
"""Return the current operation mode."""
return self._heater.display_text
+8 -1
View File
@@ -32,6 +32,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
IndevoltSystem.BYPASS_POWER,
IndevoltSystem.BYPASS_INPUT_ENERGY,
IndevoltBattery.RATED_CAPACITY,
IndevoltBattery.DAILY_CHARGING_ENERGY,
IndevoltBattery.DAILY_DISCHARGING_ENERGY,
IndevoltBattery.TOTAL_CHARGING_ENERGY,
@@ -78,7 +79,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
IndevoltSolar.DC_INPUT_POWER_2,
IndevoltSolar.DC_INPUT_POWER_3,
IndevoltSolar.DC_INPUT_POWER_4,
IndevoltBattery.RATED_CAPACITY_GEN2,
IndevoltBattery.RATED_CAPACITY,
IndevoltSystem.BYPASS_POWER,
IndevoltSystem.TOTAL_OUTPUT_ENERGY,
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
@@ -134,6 +135,12 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
IndevoltBattery.PACK_3_CURRENT,
IndevoltBattery.PACK_4_CURRENT,
IndevoltBattery.PACK_5_CURRENT,
IndevoltBattery.MAIN_CYCLES,
IndevoltBattery.PACK_1_CYCLES,
IndevoltBattery.PACK_2_CYCLES,
IndevoltBattery.PACK_3_CYCLES,
IndevoltBattery.PACK_4_CYCLES,
IndevoltBattery.PACK_5_CYCLES,
IndevoltConfig.READ_BYPASS,
IndevoltConfig.READ_GRID_CHARGING,
IndevoltConfig.READ_LIGHT,
@@ -1,6 +1,7 @@
"""Home Assistant integration for Indevolt device."""
from datetime import timedelta
import itertools
import logging
from typing import Any, Final
@@ -29,6 +30,7 @@ from .const import (
)
_LOGGER = logging.getLogger(__name__)
SCAN_BATCH_SIZE: Final = 50
SCAN_INTERVAL: Final = 30
type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator]
@@ -86,10 +88,13 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch raw JSON data from the device."""
data: dict[str, Any] = {}
sensor_keys = SENSOR_KEYS[self.generation]
try:
return await self.api.fetch_data(sensor_keys)
for chunk in itertools.batched(sensor_keys, SCAN_BATCH_SIZE, strict=False):
data.update(await self.api.fetch_data(list(chunk)))
except (ClientError, OSError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
@@ -97,6 +102,9 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
translation_placeholders={"error": str(err)},
) from err
else:
return data
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
"""Push/write data values to given key on the device."""
return await self.api.set_data(sensor_key, value)
+57 -5
View File
@@ -73,12 +73,10 @@ SENSORS: Final = (
device_class=SensorDeviceClass.ENUM,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.RATED_CAPACITY_GEN2,
generation=(2,),
key=IndevoltBattery.RATED_CAPACITY,
translation_key="rated_capacity",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
IndevoltSensorEntityDescription(
key=IndevoltConfig.READ_DISCHARGE_LIMIT,
@@ -132,7 +130,7 @@ SENSORS: Final = (
IndevoltSensorEntityDescription(
key=IndevoltBattery.GEN_2_CYCLE_COUNT,
generation=(2,),
translation_key="cycle_count",
translation_key="equivalent_full_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -794,9 +792,58 @@ SENSORS: Final = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
# Battery Pack Cycles
IndevoltSensorEntityDescription(
key=IndevoltBattery.MAIN_CYCLES,
generation=(2,),
translation_key="main_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.PACK_1_CYCLES,
generation=(2,),
translation_key="battery_pack_1_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.PACK_2_CYCLES,
generation=(2,),
translation_key="battery_pack_2_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.PACK_3_CYCLES,
generation=(2,),
translation_key="battery_pack_3_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.PACK_4_CYCLES,
generation=(2,),
translation_key="battery_pack_4_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.PACK_5_CYCLES,
generation=(2,),
translation_key="battery_pack_5_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)
# Sensors per battery pack (SN, SOC, Temperature, MOS Temperature, Voltage, Current)
# Sensors per battery pack (SN, SOC, Temperature, MOS Temperature, Voltage, Current, Cycles)
BATTERY_PACK_SENSOR_KEYS = [
(
IndevoltBattery.PACK_1_SERIAL_NUMBER,
@@ -805,6 +852,7 @@ BATTERY_PACK_SENSOR_KEYS = [
IndevoltBattery.PACK_1_MOS_TEMPERATURE,
IndevoltBattery.PACK_1_VOLTAGE,
IndevoltBattery.PACK_1_CURRENT,
IndevoltBattery.PACK_1_CYCLES,
),
(
IndevoltBattery.PACK_2_SERIAL_NUMBER,
@@ -813,6 +861,7 @@ BATTERY_PACK_SENSOR_KEYS = [
IndevoltBattery.PACK_2_MOS_TEMPERATURE,
IndevoltBattery.PACK_2_VOLTAGE,
IndevoltBattery.PACK_2_CURRENT,
IndevoltBattery.PACK_2_CYCLES,
),
(
IndevoltBattery.PACK_3_SERIAL_NUMBER,
@@ -821,6 +870,7 @@ BATTERY_PACK_SENSOR_KEYS = [
IndevoltBattery.PACK_3_MOS_TEMPERATURE,
IndevoltBattery.PACK_3_VOLTAGE,
IndevoltBattery.PACK_3_CURRENT,
IndevoltBattery.PACK_3_CYCLES,
),
(
IndevoltBattery.PACK_4_SERIAL_NUMBER,
@@ -829,6 +879,7 @@ BATTERY_PACK_SENSOR_KEYS = [
IndevoltBattery.PACK_4_MOS_TEMPERATURE,
IndevoltBattery.PACK_4_VOLTAGE,
IndevoltBattery.PACK_4_CURRENT,
IndevoltBattery.PACK_4_CYCLES,
),
(
IndevoltBattery.PACK_5_SERIAL_NUMBER,
@@ -837,6 +888,7 @@ BATTERY_PACK_SENSOR_KEYS = [
IndevoltBattery.PACK_5_MOS_TEMPERATURE,
IndevoltBattery.PACK_5_VOLTAGE,
IndevoltBattery.PACK_5_CURRENT,
IndevoltBattery.PACK_5_CYCLES,
),
]
+21 -3
View File
@@ -118,6 +118,9 @@
"battery_pack_1_current": {
"name": "Battery pack 1 current"
},
"battery_pack_1_cycles": {
"name": "Battery pack 1 cycle count"
},
"battery_pack_1_mos_temperature": {
"name": "Battery pack 1 MOS temperature"
},
@@ -136,6 +139,9 @@
"battery_pack_2_current": {
"name": "Battery pack 2 current"
},
"battery_pack_2_cycles": {
"name": "Battery pack 2 cycle count"
},
"battery_pack_2_mos_temperature": {
"name": "Battery pack 2 MOS temperature"
},
@@ -154,6 +160,9 @@
"battery_pack_3_current": {
"name": "Battery pack 3 current"
},
"battery_pack_3_cycles": {
"name": "Battery pack 3 cycle count"
},
"battery_pack_3_mos_temperature": {
"name": "Battery pack 3 MOS temperature"
},
@@ -172,6 +181,9 @@
"battery_pack_4_current": {
"name": "Battery pack 4 current"
},
"battery_pack_4_cycles": {
"name": "Battery pack 4 cycle count"
},
"battery_pack_4_mos_temperature": {
"name": "Battery pack 4 MOS temperature"
},
@@ -190,6 +202,9 @@
"battery_pack_5_current": {
"name": "Battery pack 5 current"
},
"battery_pack_5_cycles": {
"name": "Battery pack 5 cycle count"
},
"battery_pack_5_mos_temperature": {
"name": "Battery pack 5 MOS temperature"
},
@@ -226,9 +241,6 @@
"cumulative_production": {
"name": "Cumulative production"
},
"cycle_count": {
"name": "Cycle count"
},
"daily_production": {
"name": "Daily production"
},
@@ -283,6 +295,9 @@
"self_consumed_prioritized": "Self-consumed prioritized"
}
},
"equivalent_full_cycles": {
"name": "Equivalent full cycles"
},
"grid_frequency": {
"name": "Grid frequency"
},
@@ -295,6 +310,9 @@
"main_current": {
"name": "Main current"
},
"main_cycles": {
"name": "Main cycle count"
},
"main_mos_temperature": {
"name": "Main MOS temperature"
},
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==5.6.0"]
"requirements": ["infrared-protocols==5.6.1"]
}
+3 -3
View File
@@ -30,8 +30,8 @@ def log_rate_limits(
) -> None:
"""Output rate limit log line at given level."""
rate_limits = resp["rateLimits"]
resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"])
resetsAtTime = resetsAt - dt_util.utcnow() if resetsAt is not None else "---"
resets_at = dt_util.parse_datetime(rate_limits["resetsAt"])
resets_at_time = resets_at - dt_util.utcnow() if resets_at is not None else "---"
rate_limit_msg = (
"iOS push notification rate limits for %s: "
"%d sent, %d allowed, %d errors, "
@@ -44,7 +44,7 @@ def log_rate_limits(
rate_limits["successful"],
rate_limits["maximum"],
rate_limits["errors"],
str(resetsAtTime).split(".", maxsplit=1)[0],
str(resets_at_time).split(".", maxsplit=1)[0],
)
@@ -79,6 +79,17 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]
"Unable to connect and retrieve data from israelrail api",
) from e
offset = 0
now = dt_util.now()
while offset < len(train_routes):
route = train_routes[offset]
if route is None:
break
route_departure = departure_time(route)
if route_departure is None or route_departure >= now:
break
offset += 1
return [
DataConnection(
departure=departure_time(train_routes[i]),
@@ -89,6 +100,6 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]
start=station_name_to_id(train_routes[i].trains[0].src),
destination=station_name_to_id(train_routes[i].trains[-1].dst),
)
for i in range(DEPARTURES_COUNT)
for i in range(offset, offset + DEPARTURES_COUNT)
if len(train_routes) > i and train_routes[i] is not None
]
+40 -24
View File
@@ -52,30 +52,46 @@ DEPARTURE_SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
)
SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
IsraelRailSensorEntityDescription(
key="platform",
translation_key="platform",
value_fn=lambda data_connection: data_connection.platform,
),
IsraelRailSensorEntityDescription(
key="trains",
translation_key="trains",
value_fn=lambda data_connection: data_connection.trains,
),
IsraelRailSensorEntityDescription(
key="train_number",
translation_key="train_number",
value_fn=lambda data_connection: data_connection.train_number,
),
IsraelRailSensorEntityDescription(
key="departure_delay",
translation_key="departure_delay",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data_connection: data_connection.departure_delay,
),
*[
IsraelRailSensorEntityDescription(
key=f"platform{i or ''}",
translation_key=f"platform{i or ''}",
value_fn=lambda data_connection: data_connection.platform,
index=i,
)
for i in range(DEPARTURES_COUNT)
],
*[
IsraelRailSensorEntityDescription(
key=f"trains{i or ''}",
translation_key=f"trains{i or ''}",
value_fn=lambda data_connection: data_connection.trains,
index=i,
)
for i in range(DEPARTURES_COUNT)
],
*[
IsraelRailSensorEntityDescription(
key=f"train_number{i or ''}",
translation_key=f"train_number{i or ''}",
value_fn=lambda data_connection: data_connection.train_number,
index=i,
)
for i in range(DEPARTURES_COUNT)
],
*[
IsraelRailSensorEntityDescription(
key=f"departure_delay{i or ''}",
translation_key=f"departure_delay{i or ''}",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data_connection: data_connection.departure_delay,
index=i,
)
for i in range(DEPARTURES_COUNT)
],
)
@@ -31,14 +31,38 @@
"departure_delay": {
"name": "Departure delay"
},
"departure_delay1": {
"name": "Departure delay +1"
},
"departure_delay2": {
"name": "Departure delay +2"
},
"platform": {
"name": "Platform"
},
"platform1": {
"name": "Platform +1"
},
"platform2": {
"name": "Platform +2"
},
"train_number": {
"name": "Train number"
},
"train_number1": {
"name": "Train number +1"
},
"train_number2": {
"name": "Train number +2"
},
"trains": {
"name": "Trains"
},
"trains1": {
"name": "Trains +1"
},
"trains2": {
"name": "Trains +2"
}
}
}
@@ -88,6 +88,9 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = (
dict.fromkeys(_holiday.type.name for _holiday in info.holidays)
),
},
next_update_fn=lambda zmanim: (
zmanim.candle_lighting or zmanim.havdalah or zmanim.shkia.local
),
),
JewishCalendarSensorDescription(
key="omer_count",
@@ -0,0 +1,52 @@
"""The LG TV RS-232 integration."""
from lg_rs232_tv import LGTV, TVState
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_DEVICE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_SET_ID, LOGGER, QUERY_ATTRIBUTES, LGTVRS232ConfigEntry
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: LGTVRS232ConfigEntry) -> bool:
"""Set up LG TV RS-232 from a config entry."""
port = entry.data[CONF_DEVICE]
tv = LGTV(port, set_id=entry.data[CONF_SET_ID])
try:
await tv.connect()
await tv.query(QUERY_ATTRIBUTES)
except (ConnectionError, OSError, TimeoutError) as err:
if tv.connected:
await tv.disconnect()
raise ConfigEntryNotReady(f"Error connecting to LG TV: {err}") from err
entry.runtime_data = tv
@callback
def _on_disconnect(state: TVState | None) -> None:
# Only reload if the entry is still loaded. During entry removal,
# disconnect() fires this callback but the entry is already gone.
if state is None and entry.state is ConfigEntryState.LOADED:
LOGGER.warning("LG TV disconnected, reloading config entry")
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(tv.subscribe(_on_disconnect))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LGTVRS232ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
await entry.runtime_data.disconnect()
return unload_ok
@@ -0,0 +1,102 @@
"""Config flow for the LG TV RS-232 integration."""
from typing import Any
from lg_rs232_tv import DEFAULT_SET_ID, LGTV, TVNotRespondingError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SerialPortSelector,
)
from .const import CONF_SET_ID, DOMAIN, LOGGER
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_DEVICE): SerialPortSelector(),
vol.Required(CONF_SET_ID, default=DEFAULT_SET_ID): NumberSelector(
NumberSelectorConfig(min=1, max=99, mode=NumberSelectorMode.BOX)
),
}
)
# Outcome of _async_attempt_connect that means the serial port works but no LG
# TV answered it; this routes the user to the troubleshooting step.
RESULT_NO_TV = "no_tv"
async def _async_attempt_connect(port: str, set_id: int) -> str | None:
"""Attempt to connect to the TV at the given port.
Returns None on success, otherwise an outcome key: "cannot_connect" when
the serial port could not be opened, RESULT_NO_TV when the port works but
no LG TV responded to it, or "unknown" for an unexpected error.
"""
tv = LGTV(port, set_id=set_id)
try:
await tv.connect()
except TVNotRespondingError:
# The port was opened but no LG TV responded to the power query.
return RESULT_NO_TV
except ValueError, ConnectionError, OSError, TimeoutError:
# The serial port itself could not be opened.
return "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
return "unknown"
else:
await tv.disconnect()
return None
class LGTVRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LG TV RS-232."""
VERSION = 1
_user_input: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
port = user_input[CONF_DEVICE]
set_id = user_input[CONF_SET_ID]
self._async_abort_entries_match({CONF_DEVICE: port, CONF_SET_ID: set_id})
error = await _async_attempt_connect(port, set_id)
if error is None:
return self.async_create_entry(
title="LG TV",
data={CONF_DEVICE: port, CONF_SET_ID: set_id},
)
if error == RESULT_NO_TV:
self._user_input = user_input
return await self.async_step_troubleshoot()
errors["base"] = error
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
DATA_SCHEMA, user_input or self._user_input or {}
),
errors=errors,
)
async def async_step_troubleshoot(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Guide the user to enable RS-232 control after a failed connection."""
if user_input is not None:
return await self.async_step_user()
return self.async_show_form(step_id="troubleshoot")
@@ -0,0 +1,18 @@
"""Constants for the LG TV RS-232 integration."""
import logging
from lg_rs232_tv import LGTV
from homeassistant.config_entries import ConfigEntry
LOGGER = logging.getLogger(__package__)
DOMAIN = "lg_tv_rs232"
CONF_SET_ID = "set_id"
# TVState attributes the integration polls for; the TV is not asked for
# attributes the media player entity does not use.
QUERY_ATTRIBUTES = ("power", "input_source", "volume", "volume_mute", "balance")
type LGTVRS232ConfigEntry = ConfigEntry[LGTV]
@@ -0,0 +1,13 @@
{
"domain": "lg_tv_rs232",
"name": "LG TV via Serial",
"codeowners": ["@balloob"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/lg_tv_rs232",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["lg_rs232_tv"],
"quality_scale": "silver",
"requirements": ["lg-rs232-tv==1.2.0"]
}
@@ -0,0 +1,186 @@
"""Media player platform for the LG TV RS-232 integration."""
from collections.abc import Callable, Coroutine
from datetime import timedelta
from functools import wraps
from typing import Any
from lg_rs232_tv import MAX_VOLUME, CommandRejected, InputSource, PowerState, TVState
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, QUERY_ATTRIBUTES, LGTVRS232ConfigEntry
# LG TVs do not push state over RS-232, so the entity is polled.
SCAN_INTERVAL = timedelta(seconds=5)
PARALLEL_UPDATES = 1
INPUT_SOURCE_LG_TO_HA: dict[InputSource, str] = {
InputSource.DTV_ANTENNA: "dtv_antenna",
InputSource.DTV_CABLE: "dtv_cable",
InputSource.ANALOG_ANTENNA: "analog_antenna",
InputSource.ANALOG_CABLE: "analog_cable",
InputSource.AV1: "av1",
InputSource.AV2: "av2",
InputSource.COMPONENT1: "component1",
InputSource.COMPONENT2: "component2",
InputSource.COMPONENT3: "component3",
InputSource.RGB_PC: "rgb_pc",
InputSource.HDMI1: "hdmi1",
InputSource.HDMI2: "hdmi2",
InputSource.HDMI3: "hdmi3",
InputSource.HDMI4: "hdmi4",
}
INPUT_SOURCE_HA_TO_LG: dict[str, InputSource] = {
value: key for key, value in INPUT_SOURCE_LG_TO_HA.items()
}
_BASE_SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.SELECT_SOURCE
)
def catch_command_errors[**_P](
func: Callable[_P, Coroutine[Any, Any, None]],
) -> Callable[_P, Coroutine[Any, Any, None]]:
"""Translate LG library errors raised by an action into HomeAssistantError."""
@wraps(func)
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(*args, **kwargs)
except CommandRejected as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_rejected",
translation_placeholders={"error": str(err)},
) from err
except (ConnectionError, OSError, TimeoutError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={"error": str(err)},
) from err
return wrapper
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LGTVRS232ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the LG TV RS-232 media player."""
async_add_entities([LGTVRS232MediaPlayer(config_entry)])
class LGTVRS232MediaPlayer(MediaPlayerEntity):
"""Representation of an LG TV controlled over RS-232."""
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_has_entity_name = True
_attr_name = None
_attr_translation_key = "tv"
_attr_source_list = sorted(INPUT_SOURCE_LG_TO_HA.values())
def __init__(self, config_entry: LGTVRS232ConfigEntry) -> None:
"""Initialize the media player."""
self._tv = config_entry.runtime_data
self._attr_unique_id = config_entry.entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="LG",
)
self._async_update_from_state(self._tv.state)
async def async_added_to_hass(self) -> None:
"""Subscribe to TV state updates."""
self.async_on_remove(self._tv.subscribe(self._async_on_state_update))
async def async_update(self) -> None:
"""Poll the TV for its current state."""
await self._tv.query(QUERY_ATTRIBUTES)
@callback
def _async_on_state_update(self, state: TVState | None) -> None:
"""Handle a state update from the TV."""
if state is None:
self._attr_available = False
else:
self._attr_available = True
self._async_update_from_state(state)
self.async_write_ha_state()
@callback
def _async_update_from_state(self, state: TVState) -> None:
"""Update entity attributes from a TV state snapshot."""
if state.power is None:
self._attr_state = None
else:
self._attr_state = (
MediaPlayerState.ON
if state.power is PowerState.ON
else MediaPlayerState.OFF
)
source = state.input_source
self._attr_source = INPUT_SOURCE_LG_TO_HA.get(source) if source else None
# The TV only answers the balance query when its own speaker is the
# active audio output. When audio is routed elsewhere (e.g. optical),
# the TV's volume does not reflect what the user hears, so neither the
# volume controls nor the volume attributes are exposed.
features = _BASE_SUPPORTED_FEATURES
if state.balance is None:
self._attr_volume_level = None
self._attr_is_volume_muted = None
else:
features |= (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
)
self._attr_volume_level = (
None if state.volume is None else state.volume / MAX_VOLUME
)
self._attr_is_volume_muted = state.volume_mute
self._attr_supported_features = features
@catch_command_errors
async def async_turn_on(self) -> None:
"""Turn the TV on."""
await self._tv.power_on()
@catch_command_errors
async def async_turn_off(self) -> None:
"""Turn the TV off."""
await self._tv.power_off()
@catch_command_errors
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
await self._tv.set_volume(round(volume * MAX_VOLUME))
@catch_command_errors
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute the TV."""
if mute:
await self._tv.mute_on()
else:
await self._tv.mute_off()
@catch_command_errors
async def async_select_source(self, source: str) -> None:
"""Select an input source."""
await self._tv.select_input_source(INPUT_SOURCE_HA_TO_LG[source])
@@ -0,0 +1,84 @@
rules:
# Bronze
action-setup:
status: exempt
comment: The integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: The integration does not register custom actions.
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:
status: exempt
comment: The integration has no options to configure.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: The integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Serial devices are configured manually; there is no discovery.
discovery:
status: exempt
comment: RS-232 serial connections cannot be discovered.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: The integration does not create dynamic devices.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: The integration only provides a single primary entity.
entity-translations: done
exception-translations: todo
icon-translations:
status: exempt
comment: The media player entity uses its device class for its icon.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: The integration has no user-actionable issues to repair.
stale-devices:
status: exempt
comment: The integration does not create devices that can become stale.
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: The integration does not make HTTP requests.
strict-typing: done
@@ -0,0 +1,61 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"troubleshoot": {
"description": "Home Assistant could not communicate with the LG TV over the serial port.\n\nThe most common cause is that **RS-232C Control** is not enabled on the TV. On most LG models this setting is in a hidden service menu (often called **InStart**); the exact location varies by model, so check your TV's documentation.\n\nAlso make sure that:\n- The TV is powered on.\n- The serial cable is a null-modem (cross-over) cable and is fully seated. LG's RS-232 jack is recessed, so push the plug in until it clicks.\n- The correct serial port was selected.\n\nSelect **Submit** to try again.",
"title": "Connection failed"
},
"user": {
"data": {
"device": "[%key:common::config_flow::data::port%]",
"set_id": "Set ID"
},
"data_description": {
"device": "Serial port path to connect to. The TV must be powered on for the initial connection.",
"set_id": "The set ID configured on the TV. Leave this at 1 unless you have multiple TVs daisy-chained on the same RS-232 bus."
}
}
}
},
"entity": {
"media_player": {
"tv": {
"state_attributes": {
"source": {
"state": {
"analog_antenna": "Analog (antenna)",
"analog_cable": "Analog (cable)",
"av1": "AV 1",
"av2": "AV 2",
"component1": "Component 1",
"component2": "Component 2",
"component3": "Component 3",
"dtv_antenna": "Digital TV (antenna)",
"dtv_cable": "Digital TV (cable)",
"hdmi1": "HDMI 1",
"hdmi2": "HDMI 2",
"hdmi3": "HDMI 3",
"hdmi4": "HDMI 4",
"rgb_pc": "RGB PC"
}
}
}
}
}
},
"exceptions": {
"command_failed": {
"message": "Failed to send the command to the TV: {error}"
},
"command_rejected": {
"message": "The TV rejected the command: {error}"
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool:
@@ -1,5 +1,10 @@
{
"entity": {
"select": {
"room_priority": {
"default": "mdi:home-thermometer"
}
},
"sensor": {
"setpoint_status": {
"default": "mdi:thermostat"
+129
View File
@@ -0,0 +1,129 @@
"""Support for Honeywell Lyric select platform."""
import logging
from aiolyric.objects.device import LyricDevice
from aiolyric.objects.location import LyricLocation
from aiolyric.objects.priority import LyricRoom
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LYRIC_EXCEPTIONS
from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator
from .entity import LyricDeviceEntity
_LOGGER = logging.getLogger(__name__)
# Honeywell Lyric API priority types
PRIORITY_TYPE_PICK_A_ROOM = "PickARoom"
PRIORITY_TYPE_FOLLOW_ME = "FollowMe"
PRIORITY_TYPE_WHOLE_HOUSE = "WholeHouse"
# Option shown in the select for the FollowMe mode
OPTION_FOLLOW_ME = "follow_me"
async def async_setup_entry(
hass: HomeAssistant,
entry: LyricConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Honeywell Lyric select entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
LyricRoomPrioritySelect(coordinator, location, device)
for location in coordinator.data.locations
for device in location.devices
if device.device_class == "Thermostat"
and device.device_id.startswith("LCC")
and coordinator.data.rooms_dict.get(device.mac_id)
)
class LyricRoomPrioritySelect(LyricDeviceEntity, SelectEntity):
"""Select entity for Honeywell Lyric thermostat room priority."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "room_priority"
def __init__(
self,
coordinator: LyricDataUpdateCoordinator,
location: LyricLocation,
device: LyricDevice,
) -> None:
"""Initialize the room priority select entity."""
super().__init__(
coordinator,
location,
device,
f"{device.mac_id}_room_priority",
)
@property
def _rooms(self) -> dict[int, LyricRoom]:
"""Return the rooms for this thermostat."""
return self.coordinator.data.rooms_dict.get(self._mac_id, {})
@property
def options(self) -> list[str]:
"""Return the list of available room priority options."""
room_options = sorted(
room.room_name for room in self._rooms.values() if room.room_name
)
return [OPTION_FOLLOW_ME, *room_options]
@property
def current_option(self) -> str | None:
"""Return the currently selected room priority."""
priority = self.coordinator.data.priorities_dict.get(self._mac_id)
if priority is None:
return None
current = priority.current_priority
if current.priority_type == PRIORITY_TYPE_FOLLOW_ME:
return OPTION_FOLLOW_ME
if current.priority_type == PRIORITY_TYPE_PICK_A_ROOM:
selected = current.selected_rooms
if selected:
room = self._rooms.get(selected[0])
if room is not None:
return room.room_name
return None
async def async_select_option(self, option: str) -> None:
"""Set the room priority."""
if option == OPTION_FOLLOW_ME:
priority_type = PRIORITY_TYPE_FOLLOW_ME
rooms: list[int] = []
else:
priority_type = PRIORITY_TYPE_PICK_A_ROOM
room_id = next(
(rid for rid, room in self._rooms.items() if room.room_name == option),
None,
)
if room_id is None:
_LOGGER.error("Room not found: %s", option)
return
rooms = [room_id]
_LOGGER.debug("Set room priority: type=%s, rooms=%s", priority_type, rooms)
try:
await self.coordinator.data.update_priority(
self.location,
self.device,
priority_type=priority_type,
rooms=rooms,
)
except LYRIC_EXCEPTIONS as exception:
raise HomeAssistantError(
f"Failed to set room priority: {exception}"
) from exception
await self.coordinator.async_refresh()
@@ -37,6 +37,14 @@
}
},
"entity": {
"select": {
"room_priority": {
"name": "Room priority",
"state": {
"follow_me": "Follow me"
}
}
},
"sensor": {
"indoor_humidity": {
"name": "Indoor humidity"
+2 -2
View File
@@ -27,7 +27,7 @@ async def async_handle_unload(coordinator: MadVRCoordinator) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> bool:
"""Set up the integration from a config entry."""
assert entry.unique_id
madVRClient = Madvr(
mad_vr_client = Madvr(
host=entry.data[CONF_HOST],
logger=_LOGGER,
port=entry.data[CONF_PORT],
@@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> boo
connect_timeout=10,
loop=hass.loop,
)
coordinator = MadVRCoordinator(hass, entry, madVRClient)
coordinator = MadVRCoordinator(hass, entry, mad_vr_client)
entry.runtime_data = coordinator
@@ -29,8 +29,10 @@ ATTR_DISPLAY_NAME = "display_name"
ATTR_NOTE = "note"
ATTR_AVATAR = "avatar"
ATTR_AVATAR_MIME_TYPE = "avatar_mime_type"
ATTR_DELETE_AVATAR = "delete_avatar"
ATTR_HEADER = "header"
ATTR_HEADER_MIME_TYPE = "header_mime_type"
ATTR_DELETE_HEADER = "delete_header"
ATTR_BOT = "bot"
ATTR_DISCOVERABLE = "discoverable"
ATTR_FIELDS = "fields"
+20 -4
View File
@@ -38,6 +38,8 @@ from .const import (
ATTR_AVATAR_MIME_TYPE,
ATTR_BOT,
ATTR_CONTENT_WARNING,
ATTR_DELETE_AVATAR,
ATTR_DELETE_HEADER,
ATTR_DISCOVERABLE,
ATTR_DISPLAY_NAME,
ATTR_DURATION,
@@ -133,8 +135,10 @@ SERVICE_UPDATE_PROFILE_SCHEMA = vol.Schema(
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
vol.Optional(ATTR_DISPLAY_NAME): str,
vol.Optional(ATTR_NOTE): str,
vol.Optional(ATTR_AVATAR): MediaSelector({"accept": ["image/*"]}),
vol.Optional(ATTR_HEADER): MediaSelector({"accept": ["image/*"]}),
vol.Exclusive(ATTR_AVATAR, ATTR_AVATAR): MediaSelector({"accept": ["image/*"]}),
vol.Exclusive(ATTR_DELETE_AVATAR, ATTR_AVATAR): cv.boolean,
vol.Exclusive(ATTR_HEADER, ATTR_HEADER): MediaSelector({"accept": ["image/*"]}),
vol.Exclusive(ATTR_DELETE_HEADER, ATTR_HEADER): cv.boolean,
vol.Optional(ATTR_LOCKED): bool,
vol.Optional(ATTR_BOT): bool,
vol.Optional(ATTR_DISCOVERABLE): bool,
@@ -404,9 +408,21 @@ async def _async_update_profile(call: ServiceCall) -> ServiceResponse | None:
for field in fields
if field[ATTR_NAME].strip()
]
delete_avatar = params.pop("delete_avatar", False)
delete_header = params.pop("delete_header", False)
try:
response: Account = await call.hass.async_add_executor_job(
lambda: client.account_update_credentials(**params)
def _update_profile() -> Any:
if delete_avatar:
client.account_delete_avatar()
if delete_header:
client.account_delete_header()
if call.return_response or params:
return client.account_update_credentials(**params)
return None
response: Account | None = await call.hass.async_add_executor_job(
_update_profile
)
except MastodonUnauthorizedError as error:
entry.async_start_reauth(call.hass)
@@ -294,12 +294,24 @@ update_profile:
media:
accept:
- "image/*"
delete_avatar:
required: false
selector:
constant:
value: true
label: ""
header:
required: false
selector:
media:
accept:
- "image/*"
delete_header:
required: false
selector:
constant:
value: true
label: ""
locked:
selector:
boolean:
@@ -283,6 +283,14 @@
"description": "Select the Mastodon account to update the profile of.",
"name": "[%key:component::mastodon::services::post::fields::config_entry_id::name%]"
},
"delete_avatar": {
"description": "Permanently removes your current profile picture.",
"name": "Delete profile picture"
},
"delete_header": {
"description": "Permanently removes your current header picture.",
"name": "Delete header picture"
},
"discoverable": {
"description": "Whether your profile should be discoverable. Public posts and the profile may be featured or recommended across Mastodon.",
"name": "Discoverable"
+4 -4
View File
@@ -84,12 +84,12 @@ class MBCover(MicroBeesEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
sendCommand = await self.coordinator.microbees.sendCommand(
send_command = await self.coordinator.microbees.sendCommand(
self.actuator_up_id,
self.actuator_up.configuration.actuator_timing * 1000,
)
if not sendCommand:
if not send_command:
raise HomeAssistantError(f"Failed to open {self.name}")
self._attr_is_opening = True
@@ -101,11 +101,11 @@ class MBCover(MicroBeesEntity, CoverEntity):
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
sendCommand = await self.coordinator.microbees.sendCommand(
send_command = await self.coordinator.microbees.sendCommand(
self.actuator_down_id,
self.actuator_down.configuration.actuator_timing * 1000,
)
if not sendCommand:
if not send_command:
raise HomeAssistantError(f"Failed to close {self.name}")
self._attr_is_closing = True
+4 -4
View File
@@ -56,10 +56,10 @@ class MBLight(MicroBeesActuatorEntity, LightEntity):
"""Turn on the light."""
if ATTR_RGBW_COLOR in kwargs:
self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR]
sendCommand = await self.coordinator.microbees.sendCommand(
send_command = await self.coordinator.microbees.sendCommand(
self.actuator_id, 1, color=self._attr_rgbw_color
)
if not sendCommand:
if not send_command:
raise HomeAssistantError(f"Failed to turn on {self.name}")
self.actuator.value = True
@@ -67,10 +67,10 @@ class MBLight(MicroBeesActuatorEntity, LightEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
sendCommand = await self.coordinator.microbees.sendCommand(
send_command = await self.coordinator.microbees.sendCommand(
self.actuator_id, 0, color=self._attr_rgbw_color
)
if not sendCommand:
if not send_command:
raise HomeAssistantError(f"Failed to turn off {self.name}")
self.actuator.value = False
@@ -152,8 +152,8 @@ def log_rate_limits(device_name, resp, level=logging.INFO):
return
rate_limits = resp[ATTR_PUSH_RATE_LIMITS]
resetsAt = rate_limits[ATTR_PUSH_RATE_LIMITS_RESETS_AT]
resetsAtTime = dt_util.parse_datetime(resetsAt) - dt_util.utcnow()
resets_at = rate_limits[ATTR_PUSH_RATE_LIMITS_RESETS_AT]
resets_at_time = dt_util.parse_datetime(resets_at) - dt_util.utcnow()
rate_limit_msg = (
"mobile_app push notification rate limits for %s: "
"%d sent, %d allowed, %d errors, "
@@ -166,7 +166,7 @@ def log_rate_limits(device_name, resp, level=logging.INFO):
rate_limits[ATTR_PUSH_RATE_LIMITS_SUCCESSFUL],
rate_limits[ATTR_PUSH_RATE_LIMITS_MAXIMUM],
rate_limits[ATTR_PUSH_RATE_LIMITS_ERRORS],
str(resetsAtTime).split(".", maxsplit=1)[0],
str(resets_at_time).split(".", maxsplit=1)[0],
)
@@ -1 +1,41 @@
"""The opensensemap component."""
"""The openSenseMap integration."""
from opensensemap_api import OpenSenseMap
from opensensemap_api.exceptions import OpenSenseMapError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_STATION_ID
PLATFORMS: list[Platform] = [Platform.AIR_QUALITY]
type OpenSenseMapConfigEntry = ConfigEntry[OpenSenseMap]
async def async_setup_entry(
hass: HomeAssistant, entry: OpenSenseMapConfigEntry
) -> bool:
"""Set up openSenseMap from a config entry."""
session = async_get_clientsession(hass)
api = OpenSenseMap(entry.data[CONF_STATION_ID], session)
try:
await api.get_data()
except OpenSenseMapError as err:
raise ConfigEntryNotReady(
f"Unable to fetch data from openSenseMap: {err}"
) from err
entry.runtime_data = api
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: OpenSenseMapConfigEntry
) -> bool:
"""Unload an openSenseMap config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,7 +1,6 @@
"""Support for openSenseMap Air Quality data."""
from datetime import timedelta
import logging
from opensensemap_api import OpenSenseMap
from opensensemap_api.exceptions import OpenSenseMapError
@@ -11,19 +10,26 @@ from homeassistant.components.air_quality import (
PLATFORM_SCHEMA as AIR_QUALITY_PLATFORM_SCHEMA,
AirQualityEntity,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__)
CONF_STATION_ID = "station_id"
from . import OpenSenseMapConfigEntry
from .const import (
CONF_STATION_ID,
DEPRECATED_YAML_BREAKS_IN_VERSION,
DOMAIN,
INTEGRATION_TITLE,
KNOWN_IMPORT_ABORT_REASONS,
LOGGER,
)
SCAN_INTERVAL = timedelta(minutes=10)
@@ -38,23 +44,67 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the openSenseMap air quality platform."""
"""Import legacy YAML configuration into a config entry."""
# Keep the legacy platform entry point so existing YAML is migrated into a
# config entry instead of adding entities directly from YAML.
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config,
)
name = config.get(CONF_NAME)
station_id = config[CONF_STATION_ID]
if (
result["type"] is FlowResultType.ABORT
and result["reason"] in KNOWN_IMPORT_ABORT_REASONS
):
# Per-reason issue conveys the deprecation notice itself, so don't also
# raise the generic deprecated_yaml issue on top of it.
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result['reason']}",
breaks_in_ha_version=DEPRECATED_YAML_BREAKS_IN_VERSION,
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
return
session = async_get_clientsession(hass)
osm_api = OpenSenseMapData(OpenSenseMap(station_id, session))
# "deprecated_yaml" translation key lives under the "homeassistant" core domain.
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version=DEPRECATED_YAML_BREAKS_IN_VERSION,
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
await osm_api.async_update()
if "name" not in osm_api.api.data:
_LOGGER.error("Station %s is not available", station_id)
raise PlatformNotReady
station_name = osm_api.api.data["name"] if name is None else name
async_add_entities([OpenSenseMapQuality(station_name, osm_api)], True)
async def async_setup_entry(
hass: HomeAssistant,
entry: OpenSenseMapConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the openSenseMap air quality entity from a config entry."""
async_add_entities(
[
OpenSenseMapQuality(
entry.runtime_data, entry.data[CONF_STATION_ID], entry.title
)
]
)
class OpenSenseMapQuality(AirQualityEntity):
@@ -62,43 +112,28 @@ class OpenSenseMapQuality(AirQualityEntity):
_attr_attribution = "Data provided by openSenseMap"
def __init__(self, name, osm):
def __init__(self, api: OpenSenseMap, station_id: str, name: str) -> None:
"""Initialize the air quality entity."""
self._name = name
self._osm = osm
self._api = api
self._attr_name = name
self._attr_unique_id = station_id
@property
def name(self):
"""Return the name of the air quality entity."""
return self._name
@property
def particulate_matter_2_5(self):
def particulate_matter_2_5(self) -> float | None:
"""Return the particulate matter 2.5 level."""
return self._osm.api.pm2_5
return self._api.pm2_5
@property
def particulate_matter_10(self):
def particulate_matter_10(self) -> float | None:
"""Return the particulate matter 10 level."""
return self._osm.api.pm10
async def async_update(self):
"""Get the latest data from the openSenseMap API."""
await self._osm.async_update()
class OpenSenseMapData:
"""Get the latest data and update the states."""
def __init__(self, api):
"""Initialize the data object."""
self.api = api
@Throttle(SCAN_INTERVAL)
async def async_update(self):
"""Get the latest data from the Pi-hole."""
return self._api.pm10
async def async_update(self) -> None:
"""Fetch latest data from the openSenseMap API."""
try:
await self.api.get_data()
await self._api.get_data()
except OpenSenseMapError as err:
_LOGGER.error("Unable to fetch data: %s", err)
LOGGER.warning("Unable to fetch data from openSenseMap: %s", err)
self._attr_available = False
else:
self._attr_available = True
@@ -0,0 +1,89 @@
"""Config flow for the openSenseMap integration."""
from typing import Any
from opensensemap_api import OpenSenseMap
from opensensemap_api.exceptions import OpenSenseMapError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_NAME
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_STATION_ID, DOMAIN, ERROR_CANNOT_CONNECT, ERROR_INVALID_STATION
class CannotConnect(HomeAssistantError):
"""Error to indicate the openSenseMap API is unreachable."""
class InvalidStation(HomeAssistantError):
"""Error to indicate the station ID does not exist."""
class OpenSenseMapConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for openSenseMap."""
VERSION = 1
async def _async_get_station_name(self, station_id: str) -> str:
"""Validate the station ID and return its name."""
session = async_get_clientsession(self.hass)
api = OpenSenseMap(station_id, session)
try:
# opensensemap_api wraps the request in a 5s aiohttp.ClientTimeout
# and re-raises asyncio.TimeoutError as OpenSenseMapConnectionError.
await api.get_data()
except OpenSenseMapError as err:
raise CannotConnect from err
if not api.data or not api.data.get("name"):
raise InvalidStation
return api.data["name"]
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a user-initiated config flow."""
errors: dict[str, str] = {}
if user_input is not None:
station_id = user_input[CONF_STATION_ID]
try:
name = await self._async_get_station_name(station_id)
except CannotConnect:
errors["base"] = ERROR_CANNOT_CONNECT
except InvalidStation:
errors["base"] = ERROR_INVALID_STATION
else:
await self.async_set_unique_id(station_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={CONF_STATION_ID: station_id},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_STATION_ID): str}),
errors=errors,
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle import of a YAML configuration."""
station_id = import_data[CONF_STATION_ID]
await self.async_set_unique_id(station_id)
self._abort_if_unique_id_configured()
# Even when YAML provides a display name, validate the station before
# migrating so broken YAML does not create an entry that cannot set up.
try:
name = await self._async_get_station_name(station_id)
except CannotConnect:
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
except InvalidStation:
return self.async_abort(reason=ERROR_INVALID_STATION)
return self.async_create_entry(
title=import_data.get(CONF_NAME) or name,
data={CONF_STATION_ID: station_id},
)
@@ -0,0 +1,16 @@
"""Constants for the openSenseMap integration."""
import logging
DOMAIN = "opensensemap"
LOGGER = logging.getLogger(__name__)
CONF_STATION_ID = "station_id"
INTEGRATION_TITLE = "openSenseMap"
DEPRECATED_YAML_BREAKS_IN_VERSION = "2026.12.0"
ERROR_CANNOT_CONNECT = "cannot_connect"
ERROR_INVALID_STATION = "invalid_station"
KNOWN_IMPORT_ABORT_REASONS = (ERROR_CANNOT_CONNECT, ERROR_INVALID_STATION)
@@ -1,8 +1,10 @@
{
"domain": "opensensemap",
"name": "openSenseMap",
"codeowners": [],
"codeowners": ["@AlCalzone"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/opensensemap",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["opensensemap_api"],
"quality_scale": "legacy",
@@ -0,0 +1,35 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "Failed to connect to openSenseMap.",
"invalid_station": "The provided station ID does not exist on openSenseMap."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_station": "[%key:component::opensensemap::config::abort::invalid_station%]"
},
"step": {
"user": {
"data": {
"station_id": "Station ID"
},
"data_description": {
"station_id": "The unique identifier of your openSenseMap station. You can find it in the URL when viewing the station on opensensemap.org."
},
"description": "Add an openSenseMap station to monitor its measurements.",
"title": "Add openSenseMap station"
}
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to set up the integration manually.",
"title": "The {integration_title} YAML configuration import failed"
},
"deprecated_yaml_import_issue_invalid_station": {
"description": "Configuring {integration_title} using YAML is being removed but the configured station could not be found.\n\nVerify the station ID and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to set up the integration manually.",
"title": "[%key:component::opensensemap::issues::deprecated_yaml_import_issue_cannot_connect::title%]"
}
}
}
@@ -111,8 +111,11 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]):
raise ConfigEntryAuthFailed from err
except CannotConnect as err:
_LOGGER.error("Error during login: %s", err)
# pylint: disable-next=home-assistant-exception-not-translated
raise UpdateFailed(f"Error during login: {err}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="login_error",
translation_placeholders={"error": str(err)},
) from err
try:
accounts = await self.api.async_get_accounts()
@@ -124,6 +124,11 @@
}
}
},
"exceptions": {
"login_error": {
"message": "Error during login: {error}"
}
},
"issues": {
"return_to_grid_migration": {
"description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those into separate export statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Energy exported to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue.",
@@ -0,0 +1,80 @@
"""The OVHcloud AI Endpoints integration."""
from openai import AsyncOpenAI, AuthenticationError, BadRequestError, OpenAIError
from openai.types.chat import ChatCompletionUserMessageParam
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.httpx_client import get_async_client
from .const import BASE_URL
PLATFORMS = [Platform.CONVERSATION]
type OVHcloudAIEndpointsConfigEntry = ConfigEntry[AsyncOpenAI]
def _create_client(hass: HomeAssistant, api_key: str) -> AsyncOpenAI:
"""Create the AsyncOpenAI client used by this integration."""
return AsyncOpenAI(
base_url=BASE_URL,
api_key=api_key,
http_client=get_async_client(hass),
)
async def _validate_api_key(client: AsyncOpenAI) -> None:
"""Validate the API key against the chat completions endpoint.
We send a chat completion request with an unknown ``extra_body`` field
to prevent valid usage and billing.
A valid key triggers a 400 (BadRequestError), which we treat as success.
An invalid key triggers a 401 (AuthenticationError),which propagates
along with any other exception.
"""
try:
await client.with_options(timeout=10.0).chat.completions.create(
model="llama@latest",
messages=[ChatCompletionUserMessageParam(role="user", content="ping")],
extra_body={"foo": "bar"},
)
except BadRequestError:
return
async def async_setup_entry(
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
) -> bool:
"""Set up OVHcloud AI Endpoints from a config entry."""
client = _create_client(hass, entry.data[CONF_API_KEY])
try:
await _validate_api_key(client)
except AuthenticationError as err:
raise ConfigEntryAuthFailed(err) from err
except OpenAIError as err:
raise ConfigEntryNotReady(err) from err
entry.runtime_data = client
entry.async_on_unload(entry.add_update_listener(async_update_entry))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_update_entry(
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
) -> None:
"""Reload the entry when its data or subentries change."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
) -> bool:
"""Unload OVHcloud AI Endpoints."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,168 @@
"""Config flow for the OVHcloud AI Endpoints integration."""
import logging
from typing import Any
from openai import AsyncOpenAI, AuthenticationError, OpenAIError
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL
from homeassistant.core import callback
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TemplateSelector,
)
from . import _create_client, _validate_api_key
from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS
_LOGGER = logging.getLogger(__name__)
class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OVHcloud AI Endpoints."""
VERSION = 1
MINOR_VERSION = 1
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {"conversation": ConversationFlowHandler}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(user_input)
client = _create_client(self.hass, user_input[CONF_API_KEY])
try:
await _validate_api_key(client)
except AuthenticationError:
errors["base"] = "invalid_auth"
except OpenAIError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="OVHcloud AI Endpoints",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
),
errors=errors,
)
class ConversationFlowHandler(ConfigSubentryFlow):
"""Handle conversation subentry flow."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
self.models: list[str] = []
self.options: dict[str, Any] = {}
async def _get_models(self) -> None:
"""Fetch models from OVHcloud AI Endpoints."""
client: AsyncOpenAI = self._get_entry().runtime_data
self.models = [
model.id async for model in client.with_options(timeout=10.0).models.list()
]
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to create a conversation agent."""
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
return await self.async_step_init(user_input)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage conversation agent configuration."""
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
if user_input is not None:
if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API, None)
return self.async_create_entry(
title=user_input[CONF_MODEL], data=user_input
)
try:
await self._get_models()
except OpenAIError:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
options = [
SelectOptionDict(value=model_id, label=model_id) for model_id in self.models
]
hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(self.hass)
]
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(CONF_MODEL): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
sort=True,
),
),
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": self.options.get(
CONF_PROMPT,
RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT],
)
},
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
default=self.options.get(
CONF_LLM_HASS_API,
RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API],
),
): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True)
),
}
),
)
@@ -0,0 +1,16 @@
"""Constants for the OVHcloud AI Endpoints integration."""
import logging
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT
from homeassistant.helpers import llm
DOMAIN = "ovhcloud_ai_endpoints"
LOGGER = logging.getLogger(__package__)
BASE_URL = "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
@@ -0,0 +1,74 @@
"""Conversation support for OVHcloud AI Endpoints."""
from typing import Literal
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OVHcloudAIEndpointsConfigEntry
from .const import DOMAIN
from .entity import OVHcloudAIEndpointsEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OVHcloudAIEndpointsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up conversation entities."""
for subentry in config_entry.get_subentries_of_type("conversation"):
async_add_entities(
[OVHcloudAIEndpointsConversationEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class OVHcloudAIEndpointsConversationEntity(
OVHcloudAIEndpointsEntity, conversation.ConversationEntity
):
"""OVHcloud AI Endpoints conversation agent."""
_attr_name = None
def __init__(
self,
entry: OVHcloudAIEndpointsConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the agent."""
super().__init__(entry, subentry)
if self.subentry.data.get(CONF_LLM_HASS_API):
self._attr_supported_features = (
conversation.ConversationEntityFeature.CONTROL
)
@property
def supported_languages(self) -> list[str] | Literal["*"]:
"""Return a list of supported languages."""
return MATCH_ALL
async def _async_handle_message(
self,
user_input: conversation.ConversationInput,
chat_log: conversation.ChatLog,
) -> conversation.ConversationResult:
"""Process the user input and call the API."""
options = self.subentry.data
try:
await chat_log.async_provide_llm_data(
user_input.as_llm_context(DOMAIN),
options.get(CONF_LLM_HASS_API),
options.get(CONF_PROMPT),
user_input.extra_system_prompt,
)
except conversation.ConverseError as err:
return err.as_conversation_result()
await self._async_handle_chat_log(chat_log)
return conversation.async_get_result_from_chat_log(user_input, chat_log)
@@ -0,0 +1,228 @@
"""Base entity for OVHcloud AI Endpoints."""
from collections.abc import AsyncGenerator, Callable
import json
import re
from typing import Any, Literal
import openai
from openai.types.chat import (
ChatCompletionAssistantMessageParam,
ChatCompletionFunctionToolParam,
ChatCompletionMessage,
ChatCompletionMessageFunctionToolCallParam,
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam,
ChatCompletionToolMessageParam,
ChatCompletionUserMessageParam,
)
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
from openai.types.shared_params import FunctionDefinition
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_MODEL
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.json import json_dumps
from . import OVHcloudAIEndpointsConfigEntry
from .const import DOMAIN, LOGGER
MAX_TOOL_ITERATIONS = 10
_THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
def _format_tool(
tool: llm.Tool,
custom_serializer: Callable[[Any], Any] | None,
) -> ChatCompletionFunctionToolParam:
"""Format tool specification."""
tool_spec = FunctionDefinition(
name=tool.name,
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
)
if tool.description:
tool_spec["description"] = tool.description
return ChatCompletionFunctionToolParam(type="function", function=tool_spec)
def _convert_content_to_chat_message(
content: conversation.Content,
) -> ChatCompletionMessageParam | None:
"""Convert chat message for this agent to the native format."""
LOGGER.debug("_convert_content_to_chat_message=%s", content)
if isinstance(content, conversation.ToolResultContent):
return ChatCompletionToolMessageParam(
role="tool",
tool_call_id=content.tool_call_id,
content=json_dumps(content.tool_result),
)
role: Literal["user", "assistant", "system"] = content.role
if role == "system" and content.content:
return ChatCompletionSystemMessageParam(role="system", content=content.content)
if role == "user" and content.content:
return ChatCompletionUserMessageParam(role="user", content=content.content)
if role == "assistant":
param = ChatCompletionAssistantMessageParam(
role="assistant",
content=content.content,
)
if isinstance(content, conversation.AssistantContent) and content.tool_calls:
param["tool_calls"] = [
ChatCompletionMessageFunctionToolCallParam(
type="function",
id=tool_call.id,
function=Function(
arguments=json_dumps(tool_call.tool_args),
name=tool_call.tool_name,
),
)
for tool_call in content.tool_calls
]
return param
LOGGER.warning("Could not convert message to Completions API: %s", content)
return None
def _decode_tool_arguments(arguments: str) -> Any:
"""Decode tool call arguments."""
try:
return json.loads(arguments)
except json.JSONDecodeError as err:
raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err
def _split_thinking(content: str | None) -> tuple[str | None, str | None]:
"""Return (cleaned_content, thinking_content) extracted from ``<think>`` tags."""
if not content:
return content, None
thinking_parts = [m.group(1).strip() for m in _THINK_PATTERN.finditer(content)]
if not thinking_parts:
return content, None
cleaned = _THINK_PATTERN.sub("", content).strip() or None
thinking = "\n\n".join(part for part in thinking_parts if part) or None
return cleaned, thinking
def _extract_thinking(
message: ChatCompletionMessage,
) -> tuple[str | None, str | None]:
"""Return (cleaned_content, thinking_content) for an assistant message.
Priority order:
1. ``message.reasoning`` (OpenRouter, and vLLM >= 0.16.0 with a
``reasoning_parser`` configured, following OpenAI's recommendation
for gpt-oss).
2. ``message.reasoning_content`` (DeepSeek API, and vLLM < 0.16.0
with a ``reasoning_parser`` configured).
3. Inline ``<think>…</think>`` markup in ``message.content`` (any
reasoning model on vLLM without a ``reasoning_parser`` set).
"""
extras = message.model_extra or {}
for key in ("reasoning", "reasoning_content"):
value = extras.get(key)
if isinstance(value, str) and value.strip():
return message.content, value.strip()
return _split_thinking(message.content)
async def _transform_response(
message: ChatCompletionMessage,
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform the OVHcloud AI Endpoints message to a ChatLog format."""
cleaned_content, thinking_content = _extract_thinking(message)
data: conversation.AssistantContentDeltaDict = {
"role": message.role,
"content": cleaned_content,
}
if thinking_content:
data["thinking_content"] = thinking_content
if message.tool_calls:
data["tool_calls"] = [
llm.ToolInput(
id=tool_call.id,
tool_name=tool_call.function.name,
tool_args=_decode_tool_arguments(tool_call.function.arguments),
)
for tool_call in message.tool_calls
if tool_call.type == "function"
]
yield data
class OVHcloudAIEndpointsEntity(Entity):
"""Base entity for OVHcloud AI Endpoints."""
_attr_has_entity_name = True
def __init__(
self,
entry: OVHcloudAIEndpointsConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the entity."""
self.entry = entry
self.subentry = subentry
self.model = subentry.data[CONF_MODEL]
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log(self, chat_log: conversation.ChatLog) -> None:
"""Generate an answer for the chat log."""
model_args: dict[str, Any] = {
"model": self.model,
}
tools: list[ChatCompletionFunctionToolParam] | None = None
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
if tools:
model_args["tools"] = tools
model_args["messages"] = [
m
for content in chat_log.content
if (m := _convert_content_to_chat_message(content))
]
client = self.entry.runtime_data
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
result = await client.chat.completions.create(**model_args)
except openai.OpenAIError as err:
LOGGER.error("Error talking to API: %s", err)
raise HomeAssistantError("Error talking to API") from err
if not result.choices:
LOGGER.error("API returned empty choices")
raise HomeAssistantError("API returned empty response")
result_message = result.choices[0].message
model_args["messages"].extend(
[
msg
async for content in chat_log.async_add_delta_content_stream(
self.entity_id, _transform_response(result_message)
)
if (msg := _convert_content_to_chat_message(content))
]
)
if not chat_log.unresponded_tool_results:
break
@@ -0,0 +1,13 @@
{
"domain": "ovhcloud_ai_endpoints",
"name": "OVHcloud AI Endpoints",
"after_dependencies": ["assist_pipeline", "intent"],
"codeowners": ["@Crocmagnon"],
"config_flow": true,
"dependencies": ["conversation"],
"documentation": "https://www.home-assistant.io/integrations/ovhcloud_ai_endpoints",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["openai==2.21.0"]
}
@@ -0,0 +1,94 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No actions are implemented
appropriate-polling:
status: exempt
comment: the integration does not poll
brands: done
common-modules:
status: exempt
comment: the integration currently implements only one platform and has no coordinator
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No actions are implemented
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: the integration does not subscribe to events
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: configuration is per-subentry; documented via subentry strings
docs-installation-parameters: done
entity-unavailable:
status: exempt
comment: the integration only implements a stateless conversation entity.
integration-owner: done
log-when-unavailable:
status: exempt
comment: the integration only integrates stateless entities
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Service can't be discovered
discovery:
status: exempt
comment: Service can't be discovered
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: devices are created via subentries, not discovered dynamically
entity-category:
status: exempt
comment: conversation entity does not use entity categories
entity-device-class:
status: exempt
comment: no suitable device class for the conversation entity
entity-disabled-by-default:
status: exempt
comment: only one conversation entity per subentry
entity-translations:
status: exempt
comment: conversation entity name comes from subentry title
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: the integration has no repairs
stale-devices:
status: exempt
comment: only one device per entry, deleted with the subentry.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
@@ -0,0 +1,50 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "An OVHcloud AI Endpoints API key"
}
}
}
},
"config_subentries": {
"conversation": {
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before configuring.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"entry_type": "Conversation agent",
"initiate_flow": {
"user": "Add conversation agent"
},
"step": {
"init": {
"data": {
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"model": "[%key:common::generic::model%]",
"prompt": "[%key:common::config_flow::data::prompt%]"
},
"data_description": {
"llm_hass_api": "Select which tools the model can use to interact with your devices and entities.",
"model": "The model to use for the conversation agent",
"prompt": "Instruct how the LLM should respond. This can be a template."
},
"description": "Configure the conversation agent"
}
}
}
}
}
+14 -14
View File
@@ -146,14 +146,14 @@ async def determine_api_version(
debugging.
"""
holeV6 = api_by_version(hass, entry, 6, password="wrong_password")
hole_v6 = api_by_version(hass, entry, 6, password="wrong_password")
try:
await holeV6.authenticate()
await hole_v6.authenticate()
except HoleConnectionError as err:
_LOGGER.error(
"Unexpected error connecting to Pi-hole v6 API"
" at %s: %s. Trying version 5 API",
holeV6.base_url,
hole_v6.base_url,
err,
)
# Ideally python-hole would raise a specific exception for authentication failures
@@ -161,12 +161,12 @@ async def determine_api_version(
if str(ex_v6) == "Authentication failed: Invalid password":
_LOGGER.debug(
"Success connecting to Pi-hole at %s without auth, API version is : %s",
holeV6.base_url,
hole_v6.base_url,
6,
)
return 6
_LOGGER.debug(
"Connection to %s failed: %s, trying API version 5", holeV6.base_url, ex_v6
"Connection to %s failed: %s, trying API version 5", hole_v6.base_url, ex_v6
)
else:
# It seems that occasionally the auth can succeed
@@ -175,34 +175,34 @@ async def determine_api_version(
"Authenticated with %s through v6 API, but"
" succeeded with an incorrect password."
" This is a known bug",
holeV6.base_url,
hole_v6.base_url,
)
return 6
holeV5 = api_by_version(hass, entry, 5, password="wrong_token")
hole_v5 = api_by_version(hass, entry, 5, password="wrong_token")
try:
await holeV5.get_data()
await hole_v5.get_data()
except HoleConnectionError as err:
_LOGGER.error(
"Failed to connect to Pi-hole v5 API at %s: %s", holeV5.base_url, err
"Failed to connect to Pi-hole v5 API at %s: %s", hole_v5.base_url, err
)
else:
# V5 API returns [] to unauthenticated requests
if not holeV5.data:
if not hole_v5.data:
_LOGGER.debug(
"Response '[]' from API without auth,"
" pihole API version 5 probably"
" detected at %s",
holeV5.base_url,
hole_v5.base_url,
)
return 5
_LOGGER.debug(
"Unexpected response from Pi-hole API at %s: %s",
holeV5.base_url,
str(holeV5.data),
hole_v5.base_url,
str(hole_v5.data),
)
_LOGGER.debug(
"Could not determine pi-hole API version at: %s",
holeV6.base_url,
hole_v6.base_url,
)
raise HoleError("Could not determine Pi-hole API version")
@@ -84,7 +84,7 @@ async def async_setup_entry(
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
coordinator.async_add_listener(_check_outputs)
class QbusWeatherBinarySensor(QbusEntity, BinarySensorEntity):
+1 -1
View File
@@ -51,7 +51,7 @@ async def async_setup_entry(
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
coordinator.async_add_listener(_check_outputs)
class QbusClimate(QbusEntity, ClimateEntity):
+5 -10
View File
@@ -57,10 +57,7 @@ class QbusControllerCoordinator(DataUpdateCoordinator[QbusMqttDevice | None]):
self._subscribed_to_controller_state = False
self._controller: QbusMqttDevice | None = None
# Clean up when HA stops
self.config_entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown)
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown)
async def _async_update_data(self) -> QbusMqttDevice | None:
return self._controller
@@ -126,12 +123,10 @@ class QbusControllerCoordinator(DataUpdateCoordinator[QbusMqttDevice | None]):
controller_state_topic,
)
self._subscribed_to_controller_state = True
self.config_entry.async_on_unload(
await mqtt.async_subscribe(
self.hass,
controller_state_topic,
self._controller_state_received,
)
await mqtt.async_subscribe(
self.hass,
controller_state_topic,
self._controller_state_received,
)
async def _controller_state_received(self, msg: ReceiveMessage) -> None:
+1 -1
View File
@@ -45,7 +45,7 @@ async def async_setup_entry(
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
coordinator.async_add_listener(_check_outputs)
class QbusCover(QbusEntity, CoverEntity):
+1 -1
View File
@@ -36,7 +36,7 @@ async def async_setup_entry(
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
coordinator.async_add_listener(_check_outputs)
class QbusLight(QbusEntity, LightEntity):

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