Compare commits

..

4 Commits

Author SHA1 Message Date
Paul Bottein dd86e84470 Fix comments and bump lib 2026-05-27 10:49:39 +02:00
Paul Bottein b9f98274af Improve tests 2026-05-27 10:16:54 +02:00
Paul Bottein 02c7582fb6 Fix media type 2026-05-27 10:08:26 +02:00
Paul Bottein 00ebccf168 Add browse and play media support to Yoto 2026-05-27 00:20:42 +02:00
243 changed files with 3116 additions and 10862 deletions
@@ -1,52 +0,0 @@
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
+203 -96
View File
@@ -60,7 +60,9 @@ 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_VERSION: 1
APT_CACHE_BASE: /home/runner/work/apt
APT_CACHE_DIR: /home/runner/work/apt/cache
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
SQLALCHEMY_WARN_20: 1
PYTHONASYNCIODEBUG: 1
HASS_CI: 1
@@ -84,6 +86,7 @@ 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 }}
@@ -113,6 +116,10 @@ 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
@@ -377,36 +384,65 @@ 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'
if: |
steps.cache-venv.outputs.cache-hit != 'true'
|| steps.cache-apt-check.outputs.cache-hit != 'true'
id: install-os-deps
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
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
env:
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
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
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
with:
version: ${{ steps.read-uv-version.outputs.version }}
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
id: create-venv
@@ -414,6 +450,8 @@ 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
@@ -468,16 +506,30 @@ 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
@@ -824,20 +876,32 @@ 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
@@ -888,21 +952,33 @@ 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
@@ -1029,22 +1105,34 @@ 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
@@ -1178,29 +1266,36 @@ 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
@@ -1354,21 +1449,33 @@ 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,7 +337,6 @@ 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.*
@@ -429,7 +428,6 @@ 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
+2 -8
View File
@@ -987,8 +987,6 @@ 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
@@ -1292,8 +1290,6 @@ 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
@@ -1321,8 +1317,6 @@ 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
@@ -2054,8 +2048,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/ @aeon-matrix
/tests/components/yardian/ @aeon-matrix
/homeassistant/components/yardian/ @h3l1o5
/tests/components/yardian/ @h3l1o5
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/homeassistant/components/yeelightsunflower/ @lindsaymarkward
@@ -17,7 +17,6 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.EVENT,
Platform.MEDIA_PLAYER,
Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
@@ -41,7 +40,6 @@ 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,12 +8,7 @@ from aioamazondevices.exceptions import (
CannotConnect,
CannotRetrieveData,
)
from aioamazondevices.structures import (
AmazonDevice,
AmazonMediaState,
AmazonVocalRecord,
AmazonVolumeState,
)
from aioamazondevices.structures import AmazonDevice, AmazonVocalRecord
from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
@@ -79,17 +74,10 @@ 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:
@@ -201,31 +189,3 @@ 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
@@ -1,294 +0,0 @@
"""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
temperature_key = "temperature"
CONF_TEMPERATURE = "temperature"
for subentry in entry.subentries.values():
data = subentry.data.copy()
if temperature_key not in data:
if CONF_TEMPERATURE not in data:
continue
data.pop(temperature_key, None)
data.pop(CONF_TEMPERATURE, 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,3 +7,27 @@ 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",
}
+121 -19
View File
@@ -1,10 +1,11 @@
"""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,
@@ -23,9 +24,11 @@ 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 LAST_S_TEST
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
from .entity import APCUPSdEntity
@@ -33,20 +36,6 @@ 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",
@@ -60,6 +49,18 @@ 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",
@@ -99,6 +100,12 @@ 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",
@@ -125,11 +132,23 @@ 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",
@@ -245,6 +264,12 @@ 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",
@@ -333,6 +358,12 @@ 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",
@@ -373,6 +404,18 @@ 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",
@@ -438,10 +481,9 @@ async def async_setup_entry(
# as unknown initially.
#
# We also sort the resources to ensure the order of entities
# created is deterministic
# created is deterministic since "APCMODEL" and "MODEL"
# resources map to the same "Model" name.
for resource in sorted(available_resources | {LAST_S_TEST}):
if resource in IGNORED_SENSORS:
continue
if resource not in SENSORS:
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
continue
@@ -519,3 +561,63 @@ 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,5 +241,19 @@
"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,20 +49,6 @@ 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."""
@@ -201,6 +187,20 @@ 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,6 +51,7 @@ 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",
+1 -4
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": "[%key:component::aws_s3::exceptions::invalid_endpoint_url::message%]"
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
},
"step": {
"user": {
@@ -48,9 +48,6 @@
},
"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.15",
"habluetooth==6.7.9"
"dbus-fast==5.0.14",
"habluetooth==6.7.4"
]
}
+21 -14
View File
@@ -32,16 +32,8 @@ OPTIONS_SCHEMA = KNOWN_HOSTS_SCHEMA.extend(
vol.Required(CONF_MORE_OPTIONS): section(
vol.Schema(
{
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
),
),
vol.Optional(CONF_UUID): str,
vol.Optional(CONF_IGNORE_CEC): str,
}
),
SectionConfig(collapsed=True),
@@ -117,11 +109,13 @@ class CastOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the Google Cast options."""
if user_input is not None:
ignore_cec = _trim_items(
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, [])
ignore_cec = _string_to_list(
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, "")
)
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
wanted_uuid = _trim_items(user_input[CONF_MORE_OPTIONS].get(CONF_UUID, []))
wanted_uuid = _string_to_list(
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
@@ -138,7 +132,9 @@ 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] = self.config_entry.data[key]
suggested[CONF_MORE_OPTIONS][key] = _list_to_string(
self.config_entry.data[key]
)
return self.async_show_form(
step_id="init",
@@ -147,5 +143,16 @@ 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()]
@@ -1,57 +0,0 @@
"""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),
}
@@ -1,81 +0,0 @@
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,10 +16,6 @@
"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": {
@@ -28,10 +24,6 @@
"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"
}
}
@@ -175,6 +175,7 @@ class ConfigManagerFlowIndexView(
vol.Schema(
{
vol.Required("handler"): vol.Any(str, list),
vol.Optional("show_advanced_options", default=False): cv.boolean,
vol.Optional("entry_id"): cv.string,
},
extra=vol.ALLOW_EXTRA,
@@ -301,6 +302,7 @@ class SubentryManagerFlowIndexView(
vol.Schema(
{
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
vol.Optional("show_advanced_options", default=False): cv.boolean,
},
extra=vol.ALLOW_EXTRA,
)
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"quality_scale": "silver",
"requirements": ["data-grand-lyon-ha==0.7.0"]
}
@@ -49,15 +49,13 @@ rules:
status: exempt
comment: This is a service integration; there are no discoverable devices.
docs-data-update: done
docs-examples: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: This is a service integration; devices are added and removed manually by the user.
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -68,9 +66,7 @@ rules:
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
stale-devices:
status: exempt
comment: This is a service integration; devices are added and removed manually by the user.
stale-devices: done
# Platinum
async-dependency: done
@@ -38,9 +38,6 @@ 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,
@@ -382,8 +379,8 @@ async def async_extract_config(
if platform.type == PLATFORM_TYPE_LEGACY:
legacy.append(platform)
else:
async_create_platform_config_not_supported_issue(
hass, platform.name, DOMAIN
raise ValueError(
f"Unable to determine type for {platform.name}: {platform.type}"
)
return legacy
+1 -17
View File
@@ -15,7 +15,6 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
from .validation import UnsupportedBoardError, async_get_supported_board_info
_LOGGER = logging.getLogger(__name__)
@@ -44,11 +43,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
try:
box_name, _ = await self._validate_input(discovery_info.ip)
except UnsupportedBoardError:
_LOGGER.debug(
"Unsupported Duco board discovered via DHCP at %s", discovery_info.ip
)
return self.async_abort(reason="unsupported_board")
except DucoConnectionError:
return self.async_abort(reason="cannot_connect")
except DucoError:
@@ -67,12 +61,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle zeroconf discovery."""
try:
box_name, mac = await self._validate_input(discovery_info.host)
except UnsupportedBoardError:
_LOGGER.debug(
"Unsupported Duco board discovered via zeroconf at %s",
discovery_info.host,
)
return self.async_abort(reason="unsupported_board")
except DucoConnectionError:
return self.async_abort(reason="cannot_connect")
except DucoError:
@@ -114,8 +102,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
box_name, mac = await self._validate_input(user_input[CONF_HOST])
except UnsupportedBoardError:
errors["base"] = "unsupported_board"
except DucoConnectionError:
errors["base"] = "cannot_connect"
except DucoError:
@@ -147,8 +133,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
box_name, mac = await self._validate_input(user_input[CONF_HOST])
except UnsupportedBoardError:
errors["base"] = "unsupported_board"
except DucoConnectionError:
errors["base"] = "cannot_connect"
except DucoError:
@@ -178,6 +162,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
session=async_get_clientsession(self.hass),
host=host,
)
board_info = await async_get_supported_board_info(client)
board_info = await client.async_get_board_info()
lan_info = await client.async_get_lan_info()
return board_info.box_name, lan_info.mac
+16 -18
View File
@@ -4,11 +4,7 @@ from dataclasses import dataclass
import logging
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import (
DucoConnectionError,
DucoError,
DucoResponseError,
)
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco_connectivity.models import BoardInfo, Node
from homeassistant.config_entries import ConfigEntry
@@ -17,7 +13,6 @@ from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
from .validation import UnsupportedBoardError, async_get_supported_board_info
_LOGGER = logging.getLogger(__name__)
@@ -57,18 +52,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
async def _async_setup(self) -> None:
"""Fetch board info once during initial setup."""
try:
self.board_info = await async_get_supported_board_info(self.client)
except UnsupportedBoardError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="unsupported_board",
) from err
except DucoResponseError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
self.board_info = await self.client.async_get_board_info()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
@@ -86,6 +70,20 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
"""Fetch node data from the Duco box."""
try:
nodes = await self.client.async_get_nodes()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
try:
lan_info = await self.client.async_get_lan_info()
except DucoConnectionError as err:
raise UpdateFailed(
+19 -24
View File
@@ -6,13 +6,11 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device you entered belongs to a different Duco box.",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unsupported_board": "This Duco system is not supported by this integration. The integration requires a Duco Connectivity Board running public API 2.1 or newer."
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unsupported_board": "[%key:component::duco::config::abort::unsupported_board%]"
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"discovery_confirm": {
@@ -64,23 +62,23 @@
"ventilation_state": {
"name": "Ventilation state",
"state": {
"aut1": "AUT1",
"aut2": "AUT2",
"aut3": "AUT3",
"auto": "AUTO",
"cnt1": "CNT1",
"cnt2": "CNT2",
"cnt3": "CNT3",
"empt": "EMPT",
"man1": "MAN1",
"man1x2": "MAN1x2",
"man1x3": "MAN1x3",
"man2": "MAN2",
"man2x2": "MAN2x2",
"man2x3": "MAN2x3",
"man3": "MAN3",
"man3x2": "MAN3x2",
"man3x3": "MAN3x3"
"aut1": "Automatic boost (15 min)",
"aut2": "Automatic boost (30 min)",
"aut3": "Automatic boost (45 min)",
"auto": "Automatic",
"cnt1": "Continuous low speed",
"cnt2": "Continuous medium speed",
"cnt3": "Continuous high speed",
"empt": "Empty house",
"man1": "Manual low speed (15 min)",
"man1x2": "Manual low speed (30 min)",
"man1x3": "Manual low speed (45 min)",
"man2": "Manual medium speed (15 min)",
"man2x2": "Manual medium speed (30 min)",
"man2x3": "Manual medium speed (45 min)",
"man3": "Manual high speed (15 min)",
"man3x2": "Manual high speed (30 min)",
"man3x3": "Manual high speed (45 min)"
}
}
}
@@ -100,9 +98,6 @@
},
"rate_limit_exceeded": {
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
},
"unsupported_board": {
"message": "[%key:component::duco::config::abort::unsupported_board%]"
}
},
"system_health": {
@@ -1,58 +0,0 @@
"""Validation helpers for supported Duco systems."""
from awesomeversion import (
AwesomeVersion,
AwesomeVersionStrategy,
AwesomeVersionStrategyException,
)
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoResponseError
from duco_connectivity.models import BoardInfo
# Newer Connectivity boards expose /info with PublicApiVersion. We use that
# endpoint to distinguish supported Connectivity hardware from older
# Communication board V1 hardware.
_MIN_PUBLIC_API_VERSION = AwesomeVersion(
"2.1", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
)
class UnsupportedBoardError(Exception):
"""Raised when the Duco system is not supported by this integration."""
def validate_board_support(board_info: BoardInfo) -> None:
"""Raise UnsupportedBoardError if the board does not meet support requirements."""
version = board_info.public_api_version
if version is None:
raise UnsupportedBoardError("Board did not report a public API version")
try:
parsed_version = AwesomeVersion(
version, ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
)
except AwesomeVersionStrategyException as err:
raise UnsupportedBoardError(
f"Board reported malformed public API version: {version}"
) from err
if parsed_version < _MIN_PUBLIC_API_VERSION:
raise UnsupportedBoardError(
"Board public API version "
f"{version} is below the supported minimum {_MIN_PUBLIC_API_VERSION}"
)
async def async_get_supported_board_info(client: DucoClient) -> BoardInfo:
"""Fetch and validate board info for a supported Duco system."""
try:
board_info = await client.async_get_board_info()
except DucoResponseError as err:
if err.status == 404:
# Duco indicated that Communication board V1 does not implement
# /info, so a 404 is enough to treat the device as unsupported.
raise UnsupportedBoardError(
"Board does not expose the /info endpoint"
) from err
raise
validate_board_support(board_info)
return board_info
+5 -5
View File
@@ -41,7 +41,7 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
self.stations = {}
for station in stations:
label = station["label"]
rlo_id = station["RLOIid"]
rloId = 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(rlo_id, list):
rlo_id = rlo_id[-1]
if isinstance(rloId, list):
rloId = rloId[-1]
full_name = label + " - " + rlo_id
self.stations[full_name] = station["stationReference"]
fullName = label + " - " + rloId
self.stations[fullName] = station["stationReference"]
if not self.stations:
return self.async_abort(reason="no_stations")
-1
View File
@@ -40,7 +40,6 @@ 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,9 +48,6 @@
},
"speak_word": {
"service": "mdi:message-minus"
},
"switch_output_turn_on_for": {
"service": "mdi:timer"
}
}
}
@@ -161,15 +161,3 @@ 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,16 +210,6 @@
}
},
"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"
}
}
}
+1 -34
View File
@@ -1,7 +1,5 @@
"""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
@@ -9,29 +7,15 @@ 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 DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.components.switch import 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,
@@ -48,15 +32,6 @@ 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."""
@@ -76,10 +51,6 @@ 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."""
@@ -108,7 +79,3 @@ 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.3.1",
"aioesphomeapi==45.2.2",
"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")
support_ext = coordinator.data[camera].get("supportExt")
supportExt = coordinator.data[camera].get("supportExt")
if (
device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
and support_ext
and str(SupportExt.SupportBatteryManage.value) in support_ext
and supportExt
and str(SupportExt.SupportBatteryManage.value) in supportExt
):
entities.append(
EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE)
@@ -7,30 +7,19 @@ 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, DOMAIN
from .const import CONF_REFERRER
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,10 +11,5 @@
"default": "mdi:molecule"
}
}
},
"services": {
"get_forecast": {
"service": "mdi:clock-end"
}
}
}
@@ -1,107 +0,0 @@
"""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,
)
@@ -1,15 +0,0 @@
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,27 +270,8 @@
}
},
"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"})
storage_quota = res["storageQuota"]
limit = storage_quota.get("limit")
storageQuota = res["storageQuota"]
limit = storageQuota.get("limit")
return StorageQuotaData(
limit=int(limit) if limit is not None else None,
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)),
usage=int(storageQuota.get("usage", 0)),
usage_in_drive=int(storageQuota.get("usageInDrive", 0)),
usage_in_trash=int(storageQuota.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-3.1-flash-lite"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
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))
generate_content_config = self.create_generate_content_config()
generate_content_config.tools = tools or None
generate_content_config.system_instruction = (
generateContentConfig = self.create_generate_content_config()
generateContentConfig.tools = tools or None
generateContentConfig.system_instruction = (
prompt if supports_system_instruction else None
)
generate_content_config.automatic_function_calling = (
generateContentConfig.automatic_function_calling = (
AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None)
)
if structure:
generate_content_config.response_mime_type = "application/json"
generate_content_config.response_schema = _format_schema(
generateContentConfig.response_mime_type = "application/json"
generateContentConfig.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=generate_content_config
model=model_name, history=messages, config=generateContentConfig
)
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,8 +131,12 @@ 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"
@@ -173,6 +177,19 @@ 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 = {
+2 -1
View File
@@ -15,7 +15,7 @@ from aiohasupervisor.models import (
)
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_LOCATION, ATTR_NAME
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -43,6 +43,7 @@ from .const import (
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_SLUG,
DOMAIN,
+6 -5
View File
@@ -30,11 +30,6 @@ 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:
@@ -74,6 +69,12 @@ 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, override
from typing import Any
from incomfortclient import Heater as InComfortHeater
@@ -97,13 +97,11 @@ 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, override
from typing import Any
from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom
@@ -76,19 +76,16 @@ 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:
@@ -96,7 +93,6 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
return HVACAction.IDLE
@property
@override
def target_temperature(self) -> float | None:
"""Return the (override)temperature we try to reach.
@@ -110,13 +106,11 @@ 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, override
from typing import Any
from incomfortclient import InvalidGateway, InvalidHeaterList
import voluptuous as vol
@@ -100,7 +100,6 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
_discovered_host: str
@override
@staticmethod
@callback
def async_get_options_flow(
@@ -109,7 +108,6 @@ 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:
@@ -171,7 +169,6 @@ 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, override
from typing import Any
from aiohttp import ClientResponseError
from incomfortclient import (
@@ -74,7 +74,6 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
)
self.incomfort_data = incomfort_data
@override
async def _async_update_data(self) -> InComfortData:
"""Fetch data from API endpoint."""
try:
+1 -3
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, override
from typing import Any
from incomfortclient import Heater as InComfortHeater
@@ -104,13 +104,11 @@ 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, override
from typing import Any
from incomfortclient import Heater as InComfortHeater
@@ -49,13 +49,11 @@ 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:
@@ -69,7 +67,6 @@ 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
+1 -8
View File
@@ -32,7 +32,6 @@ 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,
@@ -79,7 +78,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,
IndevoltBattery.RATED_CAPACITY_GEN2,
IndevoltSystem.BYPASS_POWER,
IndevoltSystem.TOTAL_OUTPUT_ENERGY,
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
@@ -135,12 +134,6 @@ 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,7 +1,6 @@
"""Home Assistant integration for Indevolt device."""
from datetime import timedelta
import itertools
import logging
from typing import Any, Final
@@ -30,7 +29,6 @@ from .const import (
)
_LOGGER = logging.getLogger(__name__)
SCAN_BATCH_SIZE: Final = 50
SCAN_INTERVAL: Final = 30
type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator]
@@ -88,13 +86,10 @@ 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:
for chunk in itertools.batched(sensor_keys, SCAN_BATCH_SIZE, strict=False):
data.update(await self.api.fetch_data(list(chunk)))
return await self.api.fetch_data(sensor_keys)
except (ClientError, OSError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
@@ -102,9 +97,6 @@ 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)
+5 -57
View File
@@ -73,10 +73,12 @@ SENSORS: Final = (
device_class=SensorDeviceClass.ENUM,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.RATED_CAPACITY,
key=IndevoltBattery.RATED_CAPACITY_GEN2,
generation=(2,),
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,
@@ -130,7 +132,7 @@ SENSORS: Final = (
IndevoltSensorEntityDescription(
key=IndevoltBattery.GEN_2_CYCLE_COUNT,
generation=(2,),
translation_key="equivalent_full_cycles",
translation_key="cycle_count",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -792,58 +794,9 @@ 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, Cycles)
# Sensors per battery pack (SN, SOC, Temperature, MOS Temperature, Voltage, Current)
BATTERY_PACK_SENSOR_KEYS = [
(
IndevoltBattery.PACK_1_SERIAL_NUMBER,
@@ -852,7 +805,6 @@ 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,
@@ -861,7 +813,6 @@ 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,
@@ -870,7 +821,6 @@ 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,
@@ -879,7 +829,6 @@ 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,
@@ -888,7 +837,6 @@ BATTERY_PACK_SENSOR_KEYS = [
IndevoltBattery.PACK_5_MOS_TEMPERATURE,
IndevoltBattery.PACK_5_VOLTAGE,
IndevoltBattery.PACK_5_CURRENT,
IndevoltBattery.PACK_5_CYCLES,
),
]
+3 -21
View File
@@ -118,9 +118,6 @@
"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"
},
@@ -139,9 +136,6 @@
"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"
},
@@ -160,9 +154,6 @@
"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"
},
@@ -181,9 +172,6 @@
"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"
},
@@ -202,9 +190,6 @@
"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"
},
@@ -241,6 +226,9 @@
"cumulative_production": {
"name": "Cumulative production"
},
"cycle_count": {
"name": "Cycle count"
},
"daily_production": {
"name": "Daily production"
},
@@ -295,9 +283,6 @@
"self_consumed_prioritized": "Self-consumed prioritized"
}
},
"equivalent_full_cycles": {
"name": "Equivalent full cycles"
},
"grid_frequency": {
"name": "Grid frequency"
},
@@ -310,9 +295,6 @@
"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.1"]
"requirements": ["infrared-protocols==5.6.0"]
}
+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"]
resets_at = dt_util.parse_datetime(rate_limits["resetsAt"])
resets_at_time = resets_at - dt_util.utcnow() if resets_at is not None else "---"
resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"])
resetsAtTime = resetsAt - dt_util.utcnow() if resetsAt 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(resets_at_time).split(".", maxsplit=1)[0],
str(resetsAtTime).split(".", maxsplit=1)[0],
)
@@ -79,17 +79,6 @@ 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]),
@@ -100,6 +89,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(offset, offset + DEPARTURES_COUNT)
for i in range(DEPARTURES_COUNT)
if len(train_routes) > i and train_routes[i] is not None
]
+24 -40
View File
@@ -52,46 +52,30 @@ DEPARTURE_SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
)
SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
*[
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)
],
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,
),
)
@@ -31,38 +31,14 @@
"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,9 +88,6 @@ 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",
@@ -8,6 +8,8 @@ import datetime
from functools import partial
from random import random
import voluptuous as vol
from homeassistant.components.labs import (
EventLabsUpdatedData,
async_is_preview_feature_enabled,
@@ -32,7 +34,7 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.issue_registry import (
@@ -49,11 +51,9 @@ from homeassistant.util.unit_conversion import (
)
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .services import async_setup_services
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.FAN,
Platform.EVENT,
Platform.IMAGE,
@@ -69,6 +69,15 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
{
vol.Required("field_1"): vol.Coerce(int),
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
vol.Optional("field_3"): vol.Coerce(int),
vol.Optional("field_4"): vol.In(["forwards", "reverse"]),
}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the demo environment."""
@@ -78,7 +87,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
)
async_setup_services(hass)
@callback
def service_handler(call: ServiceCall | None = None) -> ServiceResponse:
"""Do nothing."""
return None
hass.services.async_register(
DOMAIN,
"test_service_1",
service_handler,
SCHEMA_SERVICE_TEST_SERVICE_1,
description_placeholders={
"meep_1": "foo",
"meep_2": "bar",
"meep_3": "beer",
"meep_4": "milk",
"meep_5": "https://example.com",
},
)
return True
@@ -1,97 +0,0 @@
"""Demo platform that has a couple of fake device trackers."""
from homeassistant.components.device_tracker import (
BaseScannerEntity,
SourceType,
TrackerEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Everything but the Kitchen Sink config entry."""
async_add_entities(
[
DemoTracker(
unique_id="kitchen_sink_tracker_001",
name="Demo tracker",
latitude=hass.config.latitude,
longitude=hass.config.longitude,
accuracy=10,
),
DemoScanner(
unique_id="kitchen_sink_scanner_001",
name="Demo scanner",
is_connected=True,
),
]
)
class DemoTracker(TrackerEntity):
"""Representation of a demo tracker."""
_attr_should_poll = False
_attr_source_type = SourceType.GPS
def __init__(
self,
*,
unique_id: str,
name: str,
latitude: float | None,
longitude: float | None,
accuracy: float,
) -> None:
"""Initialize the tracker."""
self._attr_unique_id = unique_id
self._attr_name = name
self._attr_latitude = latitude
self._attr_longitude = longitude
self._attr_location_accuracy = accuracy
@callback
def async_set_tracker_location(
self, latitude: float, longitude: float, accuracy: float
) -> None:
"""Update the tracker location."""
self._attr_latitude = latitude
self._attr_longitude = longitude
self._attr_location_accuracy = accuracy
self.async_write_ha_state()
class DemoScanner(BaseScannerEntity):
"""Representation of a demo scanner."""
_attr_should_poll = False
_attr_source_type = SourceType.ROUTER
def __init__(
self,
*,
unique_id: str,
name: str,
is_connected: bool,
) -> None:
"""Initialize the scanner."""
self._attr_unique_id = unique_id
self._attr_name = name
self._is_connected = is_connected
@property
def is_connected(self) -> bool:
"""Return true if the device is connected."""
return self._is_connected
@callback
def async_set_scanner_connected(self, connected: bool) -> None:
"""Update the scanner connected state."""
self._is_connected = connected
self.async_write_ha_state()
@@ -9,12 +9,6 @@
}
},
"services": {
"set_scanner_connected": {
"service": "mdi:lan-connect"
},
"set_tracker_location": {
"service": "mdi:map-marker"
},
"test_service_1": {
"sections": {
"additional_fields": "mdi:test-tube"
@@ -1,72 +0,0 @@
"""Services for the Everything but the Kitchen Sink integration."""
import voluptuous as vol
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
{
vol.Required("field_1"): vol.Coerce(int),
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
vol.Optional("field_3"): vol.Coerce(int),
vol.Optional("field_4"): vol.In(["forward", "reverse"]),
}
)
SERVICE_TEST_SERVICE_1 = "test_service_1"
SERVICE_SET_TRACKER_LOCATION = "set_tracker_location"
SERVICE_SET_SCANNER_CONNECTED = "set_scanner_connected"
ATTR_ACCURACY = "accuracy"
ATTR_CONNECTED = "connected"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services for the Kitchen Sink integration."""
@callback
def service_handler(call: ServiceCall) -> ServiceResponse:
"""Do nothing."""
return None
hass.services.async_register(
DOMAIN,
SERVICE_TEST_SERVICE_1,
service_handler,
SCHEMA_SERVICE_TEST_SERVICE_1,
description_placeholders={
"meep_1": "foo",
"meep_2": "bar",
"meep_3": "beer",
"meep_4": "milk",
"meep_5": "https://example.com",
},
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_TRACKER_LOCATION,
entity_domain=DEVICE_TRACKER_DOMAIN,
schema={
vol.Required(ATTR_LATITUDE): cv.latitude,
vol.Required(ATTR_LONGITUDE): cv.longitude,
vol.Required(ATTR_ACCURACY): vol.All(vol.Coerce(float), vol.Range(min=0)),
},
func="async_set_tracker_location",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_SCANNER_CONNECTED,
entity_domain=DEVICE_TRACKER_DOMAIN,
schema={vol.Required(ATTR_CONNECTED): cv.boolean},
func="async_set_scanner_connected",
)
@@ -30,44 +30,3 @@ test_service_1:
options:
- "forward"
- "reverse"
set_tracker_location:
target:
entity:
integration: kitchen_sink
domain: device_tracker
fields:
latitude:
required: true
example: 52.379189
selector:
number:
min: -90
max: 90
step: any
longitude:
required: true
example: 4.899431
selector:
number:
min: -180
max: 180
step: any
accuracy:
required: true
example: 10
selector:
number:
min: 0
max: 10000
unit_of_measurement: m
set_scanner_connected:
target:
entity:
integration: kitchen_sink
domain: device_tracker
fields:
connected:
required: true
example: true
selector:
boolean:
@@ -135,34 +135,6 @@
}
},
"services": {
"set_scanner_connected": {
"description": "Sets the connected state of a demo scanner entity.",
"fields": {
"connected": {
"description": "Whether the device should be reported as connected.",
"name": "Connected"
}
},
"name": "Set scanner connected"
},
"set_tracker_location": {
"description": "Sets the location and accuracy of a demo tracker entity.",
"fields": {
"accuracy": {
"description": "Location accuracy in meters.",
"name": "Accuracy"
},
"latitude": {
"description": "Latitude of the new location.",
"name": "Latitude"
},
"longitude": {
"description": "Longitude of the new location.",
"name": "Longitude"
}
},
"name": "Set tracker location"
},
"test_service_1": {
"description": "Fake action for testing {meep_2}",
"fields": {
@@ -1,52 +0,0 @@
"""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
@@ -1,102 +0,0 @@
"""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")
@@ -1,18 +0,0 @@
"""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]
@@ -1,13 +0,0 @@
{
"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"]
}
@@ -1,186 +0,0 @@
"""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])
@@ -1,84 +0,0 @@
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
@@ -1,61 +0,0 @@
{
"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.SELECT, Platform.SENSOR]
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool:
@@ -1,10 +1,5 @@
{
"entity": {
"select": {
"room_priority": {
"default": "mdi:home-thermometer"
}
},
"sensor": {
"setpoint_status": {
"default": "mdi:thermostat"
-129
View File
@@ -1,129 +0,0 @@
"""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,14 +37,6 @@
}
},
"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
mad_vr_client = Madvr(
madVRClient = 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, mad_vr_client)
coordinator = MadVRCoordinator(hass, entry, madVRClient)
entry.runtime_data = coordinator
@@ -29,10 +29,8 @@ 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"
+4 -20
View File
@@ -38,8 +38,6 @@ 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,
@@ -135,10 +133,8 @@ 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.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_AVATAR): MediaSelector({"accept": ["image/*"]}),
vol.Optional(ATTR_HEADER): MediaSelector({"accept": ["image/*"]}),
vol.Optional(ATTR_LOCKED): bool,
vol.Optional(ATTR_BOT): bool,
vol.Optional(ATTR_DISCOVERABLE): bool,
@@ -408,21 +404,9 @@ 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:
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
response: Account = await call.hass.async_add_executor_job(
lambda: client.account_update_credentials(**params)
)
except MastodonUnauthorizedError as error:
entry.async_start_reauth(call.hass)
@@ -294,24 +294,12 @@ 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,14 +283,6 @@
"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."""
send_command = await self.coordinator.microbees.sendCommand(
sendCommand = await self.coordinator.microbees.sendCommand(
self.actuator_up_id,
self.actuator_up.configuration.actuator_timing * 1000,
)
if not send_command:
if not sendCommand:
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."""
send_command = await self.coordinator.microbees.sendCommand(
sendCommand = await self.coordinator.microbees.sendCommand(
self.actuator_down_id,
self.actuator_down.configuration.actuator_timing * 1000,
)
if not send_command:
if not sendCommand:
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]
send_command = await self.coordinator.microbees.sendCommand(
sendCommand = await self.coordinator.microbees.sendCommand(
self.actuator_id, 1, color=self._attr_rgbw_color
)
if not send_command:
if not sendCommand:
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."""
send_command = await self.coordinator.microbees.sendCommand(
sendCommand = await self.coordinator.microbees.sendCommand(
self.actuator_id, 0, color=self._attr_rgbw_color
)
if not send_command:
if not sendCommand:
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]
resets_at = rate_limits[ATTR_PUSH_RATE_LIMITS_RESETS_AT]
resets_at_time = dt_util.parse_datetime(resets_at) - dt_util.utcnow()
resetsAt = rate_limits[ATTR_PUSH_RATE_LIMITS_RESETS_AT]
resetsAtTime = dt_util.parse_datetime(resetsAt) - 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(resets_at_time).split(".", maxsplit=1)[0],
str(resetsAtTime).split(".", maxsplit=1)[0],
)
@@ -1,41 +1 @@
"""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)
"""The opensensemap component."""
@@ -1,6 +1,7 @@
"""Support for openSenseMap Air Quality data."""
from datetime import timedelta
import logging
from opensensemap_api import OpenSenseMap
from opensensemap_api.exceptions import OpenSenseMapError
@@ -10,26 +11,19 @@ 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 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.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.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
from . import OpenSenseMapConfigEntry
from .const import (
CONF_STATION_ID,
DEPRECATED_YAML_BREAKS_IN_VERSION,
DOMAIN,
INTEGRATION_TITLE,
KNOWN_IMPORT_ABORT_REASONS,
LOGGER,
)
_LOGGER = logging.getLogger(__name__)
CONF_STATION_ID = "station_id"
SCAN_INTERVAL = timedelta(minutes=10)
@@ -44,67 +38,23 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""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,
)
"""Set up the openSenseMap air quality platform."""
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
name = config.get(CONF_NAME)
station_id = config[CONF_STATION_ID]
# "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,
},
)
session = async_get_clientsession(hass)
osm_api = OpenSenseMapData(OpenSenseMap(station_id, session))
await osm_api.async_update()
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
)
]
)
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)
class OpenSenseMapQuality(AirQualityEntity):
@@ -112,28 +62,43 @@ class OpenSenseMapQuality(AirQualityEntity):
_attr_attribution = "Data provided by openSenseMap"
def __init__(self, api: OpenSenseMap, station_id: str, name: str) -> None:
def __init__(self, name, osm):
"""Initialize the air quality entity."""
self._api = api
self._attr_name = name
self._attr_unique_id = station_id
self._name = name
self._osm = osm
@property
def particulate_matter_2_5(self) -> float | None:
def name(self):
"""Return the name of the air quality entity."""
return self._name
@property
def particulate_matter_2_5(self):
"""Return the particulate matter 2.5 level."""
return self._api.pm2_5
return self._osm.api.pm2_5
@property
def particulate_matter_10(self) -> float | None:
def particulate_matter_10(self):
"""Return the particulate matter 10 level."""
return self._api.pm10
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."""
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.warning("Unable to fetch data from openSenseMap: %s", err)
self._attr_available = False
else:
self._attr_available = True
_LOGGER.error("Unable to fetch data: %s", err)
@@ -1,89 +0,0 @@
"""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},
)
@@ -1,16 +0,0 @@
"""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,10 +1,8 @@
{
"domain": "opensensemap",
"name": "openSenseMap",
"codeowners": ["@AlCalzone"],
"config_flow": true,
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/opensensemap",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["opensensemap_api"],
"quality_scale": "legacy",
@@ -1,35 +0,0 @@
{
"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%]"
}
}
}
+73 -103
View File
@@ -1,5 +1,7 @@
"""Support for OPNsense Routers."""
import logging
from aiopnsense import (
OPNsenseBelowMinFirmware,
OPNsenseClient,
@@ -13,16 +15,22 @@ from aiopnsense import (
)
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
from .const import CONF_API_SECRET, CONF_TRACKER_INTERFACES, DOMAIN
from .types import OPNsenseConfigEntry, OPNsenseRuntimeData
from .const import (
CONF_API_SECRET,
CONF_INTERFACE_CLIENT,
CONF_TRACKER_INTERFACES,
DOMAIN,
OPNSENSE_DATA,
)
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
@@ -41,124 +49,86 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
PLATFORMS = [Platform.DEVICE_TRACKER]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the OPNsense component."""
if DOMAIN not in config:
return True
"""Set up the opnsense component."""
hass.async_create_task(_async_setup(hass, config))
conf = config[DOMAIN]
url = conf[CONF_URL]
api_key = conf[CONF_API_KEY]
api_secret = conf[CONF_API_SECRET]
verify_ssl = conf[CONF_VERIFY_SSL]
tracker_interfaces = conf[CONF_TRACKER_INTERFACES]
return True
async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None:
"""Set up the OPNsense component from YAML."""
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config[DOMAIN],
)
async def async_setup_entry(
hass: HomeAssistant, config_entry: OPNsenseConfigEntry
) -> bool:
"""Set up the OPNsense component from a config entry."""
url = config_entry.data[CONF_URL]
session = async_get_clientsession(
hass, verify_ssl=config_entry.data[CONF_VERIFY_SSL]
)
session = async_get_clientsession(hass, verify_ssl=verify_ssl)
client = OPNsenseClient(
url,
config_entry.data[CONF_API_KEY],
config_entry.data[CONF_API_SECRET],
api_key,
api_secret,
session,
opts={"verify_ssl": config_entry.data[CONF_VERIFY_SSL]},
opts={"verify_ssl": verify_ssl},
)
tracker_interfaces = config_entry.data.get(CONF_TRACKER_INTERFACES, [])
try:
await client.validate()
if tracker_interfaces:
interfaces_resp = await client.get_interfaces()
except OPNsenseUnknownFirmware as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="unknown_firmware",
translation_placeholders={"url": url},
) from err
except OPNsenseBelowMinFirmware as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="firmware_too_old",
translation_placeholders={"url": url},
) from err
except OPNsenseInvalidURL as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_url",
translation_placeholders={"url": url},
) from err
except OPNsenseTimeoutError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="timeout_connecting",
translation_placeholders={"url": url},
) from err
except OPNsenseSSLError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="ssl_error",
translation_placeholders={"url": url},
) from err
except OPNsenseInvalidAuth as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"url": url},
) from err
except OPNsensePrivilegeMissing as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="privilege_missing",
translation_placeholders={"url": url},
) from err
except OPNsenseConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"url": url},
) from err
except OPNsenseUnknownFirmware:
_LOGGER.error("Error checking the OPNsense firmware version at %s", url)
return False
except OPNsenseBelowMinFirmware:
_LOGGER.error(
"OPNsense Firmware is below the minimum supported version at %s", url
)
return False
except OPNsenseInvalidURL:
_LOGGER.error(
"Invalid URL while connecting to OPNsense API endpoint at %s", url
)
return False
except OPNsenseTimeoutError:
_LOGGER.error("Timeout while connecting to OPNsense API endpoint at %s", url)
return False
except OPNsenseSSLError:
_LOGGER.error(
"Unable to verify SSL while connecting to OPNsense API endpoint at %s", url
)
return False
except OPNsenseInvalidAuth:
_LOGGER.error(
"Authentication failure while connecting to OPNsense API endpoint at %s",
url,
)
return False
except OPNsensePrivilegeMissing:
_LOGGER.error(
"Invalid Permissions while connecting to OPNsense API endpoint at %s",
url,
)
return False
except OPNsenseConnectionError:
_LOGGER.error(
"Connection failure while connecting to OPNsense API endpoint at %s",
url,
)
return False
if tracker_interfaces:
# Verify that specified tracker interfaces are valid
known_interfaces = [
name for ifinfo in interfaces_resp.values() if (name := ifinfo.get("name"))
ifinfo.get("name", "") for ifinfo in interfaces_resp.values()
]
for intf_description in tracker_interfaces:
if intf_description not in known_interfaces:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="tracker_interface_not_found",
translation_placeholders={
"interface": intf_description,
"known": ", ".join(known_interfaces),
},
_LOGGER.error(
"Specified OPNsense tracker interface %s is not found",
intf_description,
)
return False
config_entry.runtime_data = OPNsenseRuntimeData(
client=client,
tracker_interfaces=tracker_interfaces,
)
hass.data[OPNSENSE_DATA] = {
CONF_INTERFACE_CLIENT: client,
CONF_TRACKER_INTERFACES: tracker_interfaces,
}
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
load_platform(hass, Platform.DEVICE_TRACKER, DOMAIN, tracker_interfaces, config)
return True
async def async_unload_entry(
hass: HomeAssistant, config_entry: OPNsenseConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
@@ -1,315 +0,0 @@
"""Config flow for OPNsense."""
import logging
from typing import Any
from aiopnsense import (
OPNsenseBelowMinFirmware,
OPNsenseClient,
OPNsenseConnectionError,
OPNsenseInvalidAuth,
OPNsenseInvalidURL,
OPNsensePrivilegeMissing,
OPNsenseSSLError,
OPNsenseTimeoutError,
OPNsenseUnknownFirmware,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_API_SECRET, CONF_TRACKER_INTERFACES, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): str,
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_API_SECRET): str,
vol.Required(CONF_VERIFY_SSL, default=True): bool,
}
)
def tracker_interfaces_schema(
interfaces: list[str], selected: list[str] | None = None
) -> vol.Schema:
"""Schema to display available interfaces for device tracking selection."""
return vol.Schema(
{
vol.Optional(
CONF_TRACKER_INTERFACES,
default=selected or [],
): SelectSelector(
SelectSelectorConfig(
options=interfaces, mode=SelectSelectorMode.DROPDOWN, multiple=True
)
),
}
)
class OPNsenseConfigFlow(ConfigFlow, domain=DOMAIN):
"""OPNsense config flow."""
def __init__(self) -> None:
"""Initialize OPNsense config flow."""
self.available_interfaces: list[str] | None = None
self._entry_data: dict[str, Any] = {}
async def _show_setup_form(
self,
user_input: dict[Any, Any] | None = None,
errors: dict[Any, Any] | None = None,
) -> ConfigFlowResult:
"""Show the setup form to the user."""
if user_input is None:
user_input = {}
description_placeholders = {
"doc_url": "https://www.home-assistant.io/integrations/opnsense/"
}
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors or {},
description_placeholders=description_placeholders,
)
async def _show_interfaces_form(
self,
user_input: dict[Any, Any],
errors: dict[Any, Any] | None = None,
) -> ConfigFlowResult:
"""Show the tracker interfaces selection form to the user."""
return self.async_show_form(
step_id="interfaces",
data_schema=self.add_suggested_values_to_schema(
tracker_interfaces_schema(
self.available_interfaces or [],
user_input.get(CONF_TRACKER_INTERFACES),
),
user_input,
),
errors=errors or {},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user step: credentials and connection test."""
errors = {}
if user_input is None:
return await self._show_setup_form(user_input, None)
verify_ssl = user_input[CONF_VERIFY_SSL]
session = async_get_clientsession(self.hass, verify_ssl=verify_ssl)
client = OPNsenseClient(
user_input[CONF_URL],
user_input[CONF_API_KEY],
user_input[CONF_API_SECRET],
session,
opts={"verify_ssl": verify_ssl},
)
try:
await client.validate()
interfaces_resp = await client.get_interfaces()
known_interfaces = [
name
for ifinfo in interfaces_resp.values()
if (name := ifinfo.get("name"))
]
self.available_interfaces = list(known_interfaces)
except OPNsenseInvalidAuth:
errors["base"] = "invalid_auth"
except OPNsensePrivilegeMissing:
errors["base"] = "privilege_missing"
except OPNsenseInvalidURL:
errors["base"] = "invalid_url"
except OPNsenseSSLError:
errors["base"] = "ssl_error"
except OPNsenseConnectionError, OPNsenseTimeoutError:
errors["base"] = "cannot_connect"
except OPNsenseUnknownFirmware:
errors["base"] = "unknown_version"
except OPNsenseBelowMinFirmware:
errors["base"] = "invalid_version"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
unique_id = await client.get_device_unique_id()
if not unique_id:
return self.async_abort(reason="no_unique_id")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
self._entry_data = user_input
return await self.async_step_interfaces()
return await self._show_setup_form(user_input, errors)
async def async_step_interfaces(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle tracker interface selection step."""
if user_input is None:
return await self._show_interfaces_form({}, None)
if user_input.get(CONF_TRACKER_INTERFACES):
self._entry_data[CONF_TRACKER_INTERFACES] = user_input[
CONF_TRACKER_INTERFACES
]
return self.async_create_entry(
title=self._entry_data[CONF_URL], data=self._entry_data
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import a Yaml config."""
# Test connection
session = async_get_clientsession(
self.hass, verify_ssl=import_data[CONF_VERIFY_SSL]
)
client = OPNsenseClient(
import_data[CONF_URL],
import_data[CONF_API_KEY],
import_data[CONF_API_SECRET],
session,
opts={"verify_ssl": import_data[CONF_VERIFY_SSL]},
)
try:
await client.validate()
interfaces_resp = await client.get_interfaces()
except OPNsenseInvalidURL:
return self._abort_import(reason="invalid_url")
except OPNsenseInvalidAuth:
return self._abort_import(reason="invalid_auth")
except OPNsensePrivilegeMissing:
return self._abort_import(reason="privilege_missing")
except OPNsenseSSLError:
return self._abort_import(reason="ssl_error")
except OPNsenseConnectionError, OPNsenseTimeoutError:
return self._abort_import(reason="cannot_connect")
except OPNsenseUnknownFirmware:
return self._abort_import(reason="unknown_version")
except OPNsenseBelowMinFirmware:
return self._abort_import(reason="invalid_version")
except Exception: # Allowed in config flows
_LOGGER.exception("Unexpected exception during import")
return self._abort_import(reason="unknown")
async_create_issue(
self.hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2026.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "OPNsense",
},
)
unique_id = await client.get_device_unique_id()
if not unique_id:
return self._abort_import(reason="no_unique_id")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
# Validate CONF_TRACKER_INTERFACES if present and not empty
verified_data = dict(import_data)
if CONF_TRACKER_INTERFACES in verified_data:
if not verified_data[CONF_TRACKER_INTERFACES]:
verified_data.pop(CONF_TRACKER_INTERFACES)
else:
known_interfaces = [
name
for ifinfo in interfaces_resp.values()
if (name := ifinfo.get("name"))
]
self.available_interfaces = sorted(known_interfaces)
# Abort import if any specified tracker interface is not found
missing = [
intf_description
for intf_description in verified_data[CONF_TRACKER_INTERFACES]
if intf_description not in known_interfaces
]
if missing:
# Create a repair to guide the user
async_create_issue(
self.hass,
DOMAIN,
"import_failed_missing_interfaces",
breaks_in_ha_version="2026.12.0",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="import_failed_missing_interfaces",
translation_placeholders={
"missing": ", ".join(missing),
"found": ", ".join(known_interfaces),
"integration_title": "OPNsense",
},
)
return self.async_abort(
reason="import_failed_missing_interfaces",
description_placeholders={
"missing": ", ".join(missing),
"found": ", ".join(known_interfaces),
"integration_title": "OPNsense",
},
)
# Clear any previous import issues if interfaces are now valid
async_delete_issue(
self.hass,
DOMAIN,
"import_failed_missing_interfaces",
)
return self.async_create_entry(
title=verified_data[CONF_URL], data=verified_data
)
def _abort_import(self, reason: str) -> ConfigFlowResult:
"""Create an issue for import errors and abort the import."""
async_create_issue(
self.hass,
DOMAIN,
f"import_failed_{reason}",
breaks_in_ha_version="2026.12.0",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key=f"import_failed_{reason}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "OPNsense",
},
)
return self.async_abort(
reason=reason,
description_placeholders={
"integration_title": "OPNsense",
},
)
+2 -5
View File
@@ -1,11 +1,8 @@
"""Constants for OPNsense component."""
from datetime import timedelta
DOMAIN = "opnsense"
OPNSENSE_DATA = DOMAIN
CONF_API_SECRET = "api_secret"
CONF_INTERFACE_CLIENT = "interface_client"
CONF_TRACKER_INTERFACES = "tracker_interfaces"
# Update interval for device scanning
SCAN_INTERVAL = timedelta(seconds=30)
@@ -1,80 +0,0 @@
"""Coordinator for OPNsense device tracker updates."""
import logging
from aiopnsense import (
OPNsenseBelowMinFirmware,
OPNsenseClient,
OPNsenseConnectionError,
OPNsenseInvalidAuth,
OPNsenseInvalidURL,
OPNsensePrivilegeMissing,
OPNsenseSSLError,
OPNsenseTimeoutError,
OPNsenseUnknownFirmware,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import SCAN_INTERVAL
from .types import DeviceDetails, DeviceDetailsByMAC, OPNsenseConfigEntry
_LOGGER = logging.getLogger(__name__)
class OPNsenseDeviceTrackerCoordinator(DataUpdateCoordinator[DeviceDetailsByMAC]):
"""Coordinator for OPNsense device tracker updates."""
def __init__(
self,
hass: HomeAssistant,
config_entry: OPNsenseConfigEntry,
client: OPNsenseClient,
interfaces: list[str],
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name="OPNsense Device Tracker",
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
)
self.client = client
self.interfaces = interfaces
self.tracked_devices: set[str] = set()
def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC:
"""Create dict with mac address keys from list of devices."""
out_devices: DeviceDetailsByMAC = {}
for device in devices:
if not self.interfaces or device["intf_description"] in self.interfaces:
formatted_mac = format_mac(device["mac"])
out_devices[formatted_mac] = device
return out_devices
async def _async_update_data(self) -> DeviceDetailsByMAC:
"""Fetch data from OPNsense."""
try:
devices = await self.client.get_arp_table(True)
except (
OPNsenseInvalidAuth,
OPNsenseInvalidURL,
OPNsensePrivilegeMissing,
OPNsenseSSLError,
OPNsenseBelowMinFirmware,
OPNsenseUnknownFirmware,
) as err:
raise ConfigEntryError(f"Error with OPNsense configuration: {err}") from err
except (
OPNsenseConnectionError,
OPNsenseTimeoutError,
) as err:
raise UpdateFailed(
f"Error communicating with OPNsense router: {err}"
) from err
return self._get_mac_addrs(devices)
@@ -1,117 +1,71 @@
"""Device tracker support for OPNsense routers."""
from typing import Any
from typing import Any, NewType
from homeassistant.components.device_tracker import ScannerEntity
from aiopnsense import OPNsenseClient
from homeassistant.components.device_tracker import DeviceScanner
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.typing import ConfigType
from .coordinator import OPNsenseDeviceTrackerCoordinator
from .types import DeviceDetails, OPNsenseConfigEntry
from .const import CONF_INTERFACE_CLIENT, CONF_TRACKER_INTERFACES, OPNSENSE_DATA
DeviceDetails = NewType("DeviceDetails", dict[str, Any])
DeviceDetailsByMAC = NewType("DeviceDetailsByMAC", dict[str, DeviceDetails])
async def async_setup_entry(
hass: HomeAssistant,
entry: OPNsenseConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for OPNsense component."""
client = entry.runtime_data.client
interfaces = entry.runtime_data.tracker_interfaces
coordinator = OPNsenseDeviceTrackerCoordinator(hass, entry, client, interfaces)
def _async_add_new_entities() -> None:
"""Add entities for newly discovered devices."""
if not coordinator.data:
return
entities = []
for mac_address in coordinator.data:
if mac_address in coordinator.tracked_devices:
continue
entity = OPNsenseDeviceTrackerEntity(coordinator, mac_address)
coordinator.tracked_devices.add(mac_address)
entities.append(entity)
if entities:
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
# Initial data fetch
await coordinator.async_config_entry_first_refresh()
_async_add_new_entities()
async def async_get_scanner(
hass: HomeAssistant, config: ConfigType
) -> DeviceScanner | None:
"""Configure the OPNsense device_tracker."""
return OPNsenseDeviceScanner(
hass.data[OPNSENSE_DATA][CONF_INTERFACE_CLIENT],
hass.data[OPNSENSE_DATA][CONF_TRACKER_INTERFACES],
)
class OPNsenseDeviceTrackerEntity(
CoordinatorEntity[OPNsenseDeviceTrackerCoordinator], ScannerEntity
):
"""Representation of a tracked device."""
class OPNsenseDeviceScanner(DeviceScanner):
"""This class queries a router running OPNsense."""
def __init__(
self,
coordinator: OPNsenseDeviceTrackerCoordinator,
mac_address: str,
) -> None:
"""Initialize the device tracker entity."""
super().__init__(coordinator)
self._attr_mac_address = mac_address
def __init__(self, client: OPNsenseClient, interfaces: list[str]) -> None:
"""Initialize the scanner."""
self.last_results: dict[str, Any] = {}
self.client = client
self.interfaces = interfaces
@property
def device_data(self) -> DeviceDetails | None:
"""Return device data for current device."""
if self.coordinator.data and self.mac_address in self.coordinator.data:
return self.coordinator.data[self.mac_address]
return None
def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC | dict:
"""Create dict with mac address keys from list of devices."""
out_devices = {}
for device in devices:
if not self.interfaces or device["intf_description"] in self.interfaces:
out_devices[device["mac"]] = device
return out_devices
@property
def is_connected(self) -> bool:
"""Return true if the device is connected to the network."""
return (
self.coordinator.data is not None
and self.mac_address in self.coordinator.data
)
async def async_scan_devices(self) -> list[str]:
"""Scan for new devices and return a list with found device IDs."""
await self._async_update_info()
return list(self.last_results)
@property
def name(self) -> str:
"""Return device name."""
device_data = self.device_data
if device_data and device_data.get("hostname"):
return str(device_data["hostname"])
return f"OPNsense {self.mac_address}"
def get_device_name(self, device: str) -> str | None:
"""Return the name of the given device or None if we don't know."""
if device not in self.last_results:
return None
return self.last_results[device].get("hostname") or None
@property
def ip_address(self) -> str | None:
"""Return the primary IP address of the device."""
device_data = self.device_data
if device_data:
return device_data.get("ip")
return None
async def _async_update_info(self) -> bool:
"""Ensure the information from the OPNsense router is up to date.
@property
def hostname(self) -> str | None:
"""Return hostname of the device."""
device_data = self.device_data
if device_data:
hostname = device_data.get("hostname")
return hostname or None
return None
Return boolean if scanning successful.
"""
devices = await self.client.get_arp_table(True)
self.last_results = self._get_mac_addrs(devices)
return True
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
device_data = self.device_data
if not device_data:
def get_extra_attributes(self, device: str) -> dict[Any, Any]:
"""Return the extra attrs of the given device."""
if device not in self.last_results:
return {}
attrs = {}
if manufacturer := device_data.get("manufacturer"):
attrs["manufacturer"] = manufacturer
if interface := device_data.get("intf_description"):
attrs["interface"] = interface
if expires := device_data.get("expires"):
attrs["expires"] = expires
return attrs
mfg = self.last_results[device].get("manufacturer")
if not mfg:
return {}
return {"manufacturer": mfg}
@@ -2,7 +2,6 @@
"domain": "opnsense",
"name": "OPNsense",
"codeowners": ["@HarlemSquirrel", "@Snuffy2"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/opnsense",
"integration_type": "hub",
"iot_class": "local_polling",

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