Compare commits

..

1 Commits

Author SHA1 Message Date
jbouwh
79738cfa0d Allow to wait for MQTT subscription 2025-09-25 18:54:38 +00:00
226 changed files with 1655 additions and 14942 deletions

View File

@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 8 CACHE_VERSION: 8
UV_CACHE_VERSION: 1 UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.11" HA_SHORT_VERSION: "2025.10"
DEFAULT_PYTHON: "3.13" DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']" ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version # 10.3 is the oldest supported version
@@ -263,7 +263,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
key: >- key: >-
@@ -279,7 +279,7 @@ jobs:
uv pip install "$(cat requirements_test.txt | grep pre-commit)" uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true lookup-only: true
@@ -309,7 +309,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -318,7 +318,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -349,7 +349,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -358,7 +358,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -389,7 +389,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -398,7 +398,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -505,7 +505,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
key: >- key: >-
@@ -513,7 +513,7 @@ jobs:
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache - name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: ${{ env.UV_CACHE_DIR }} path: ${{ env.UV_CACHE_DIR }}
key: >- key: >-
@@ -525,7 +525,7 @@ jobs:
env.HA_SHORT_VERSION }}- env.HA_SHORT_VERSION }}-
- name: Check if apt cache exists - name: Check if apt cache exists
id: cache-apt-check id: cache-apt-check
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }} lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: | path: |
@@ -570,7 +570,7 @@ jobs:
fi fi
- name: Save apt cache - name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true' if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -622,7 +622,7 @@ jobs:
- base - base
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@v4.3.0 uses: actions/cache/restore@v4.2.4
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -651,7 +651,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -684,7 +684,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -741,7 +741,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -784,7 +784,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -831,7 +831,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -883,7 +883,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -891,7 +891,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Restore mypy cache - name: Restore mypy cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: .mypy_cache path: .mypy_cache
key: >- key: >-
@@ -935,7 +935,7 @@ jobs:
name: Split tests for full run name: Split tests for full run
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@v4.3.0 uses: actions/cache/restore@v4.2.4
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -967,7 +967,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1009,7 +1009,7 @@ jobs:
Run tests Python ${{ matrix.python-version }} (${{ matrix.group }}) Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@v4.3.0 uses: actions/cache/restore@v4.2.4
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -1042,7 +1042,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1156,7 +1156,7 @@ jobs:
Run ${{ matrix.mariadb-group }} tests Python ${{ matrix.python-version }} Run ${{ matrix.mariadb-group }} tests Python ${{ matrix.python-version }}
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@v4.3.0 uses: actions/cache/restore@v4.2.4
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -1189,7 +1189,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1310,7 +1310,7 @@ jobs:
Run ${{ matrix.postgresql-group }} tests Python ${{ matrix.python-version }} Run ${{ matrix.postgresql-group }} tests Python ${{ matrix.python-version }}
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@v4.3.0 uses: actions/cache/restore@v4.2.4
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -1345,7 +1345,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1485,7 +1485,7 @@ jobs:
Run tests Python ${{ matrix.python-version }} (${{ matrix.group }}) Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
steps: steps:
- name: Restore apt cache - name: Restore apt cache
uses: actions/cache/restore@v4.3.0 uses: actions/cache/restore@v4.2.4
with: with:
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
@@ -1518,7 +1518,7 @@ jobs:
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
with: with:
category: "/language:python" category: "/language:python"

View File

@@ -160,7 +160,7 @@ jobs:
# home-assistant/wheels doesn't support sha pinning # home-assistant/wheels doesn't support sha pinning
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@2025.09.1 uses: home-assistant/wheels@2025.07.0
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@@ -221,7 +221,7 @@ jobs:
# home-assistant/wheels doesn't support sha pinning # home-assistant/wheels doesn't support sha pinning
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@2025.09.1 uses: home-assistant/wheels@2025.07.0
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ from airos.exceptions import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL from .const import DOMAIN, SCAN_INTERVAL
@@ -47,9 +47,9 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
try: try:
await self.airos_device.login() await self.airos_device.login()
return await self.airos_device.status() return await self.airos_device.status()
except AirOSConnectionAuthenticationError as err: except (AirOSConnectionAuthenticationError,) as err:
_LOGGER.exception("Error authenticating with airOS device") _LOGGER.exception("Error authenticating with airOS device")
raise ConfigEntryAuthFailed( raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="invalid_auth" translation_domain=DOMAIN, translation_key="invalid_auth"
) from err ) from err
except ( except (

View File

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

View File

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

View File

@@ -2,14 +2,6 @@
"config": { "config": {
"flow_title": "Ubiquiti airOS device", "flow_title": "Ubiquiti airOS device",
"step": { "step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::user::data_description::password%]"
}
},
"user": { "user": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
@@ -20,18 +12,6 @@
"host": "IP address or hostname of the airOS device", "host": "IP address or hostname of the airOS device",
"username": "Administrator username for the airOS device, normally 'ubnt'", "username": "Administrator username for the airOS device, normally 'ubnt'",
"password": "Password configured through the UISP app or web interface" "password": "Password configured through the UISP app or web interface"
},
"sections": {
"advanced_settings": {
"data": {
"ssl": "Use HTTPS",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "Whether the connection should be encrypted (required for most devices)",
"verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates"
}
}
} }
} }
}, },
@@ -42,9 +22,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "Re-authentication should be used for the same device not a new one"
} }
}, },
"entity": { "entity": {

View File

@@ -10,7 +10,6 @@ from aioamazondevices.api import AmazonDevice
from aioamazondevices.const import SENSOR_STATE_OFF from aioamazondevices.const import SENSOR_STATE_OFF
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription, BinarySensorEntityDescription,
@@ -21,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity from .entity import AmazonEntity
from .utils import async_update_unique_id
# Coordinator is used to centralize the data updates # Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@@ -33,7 +31,6 @@ class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
is_on_fn: Callable[[AmazonDevice, str], bool] is_on_fn: Callable[[AmazonDevice, str], bool]
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: True
BINARY_SENSORS: Final = ( BINARY_SENSORS: Final = (
@@ -44,15 +41,46 @@ BINARY_SENSORS: Final = (
is_on_fn=lambda device, _: device.online, is_on_fn=lambda device, _: device.online,
), ),
AmazonBinarySensorEntityDescription( AmazonBinarySensorEntityDescription(
key="detectionState", key="bluetooth",
device_class=BinarySensorDeviceClass.MOTION, entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda device, key: bool( translation_key="bluetooth",
device.sensors[key].value != SENSOR_STATE_OFF is_on_fn=lambda device, _: device.bluetooth_state,
), ),
AmazonBinarySensorEntityDescription(
key="babyCryDetectionState",
translation_key="baby_cry_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None, is_supported=lambda device, key: device.sensors.get(key) is not None,
is_available_fn=lambda device, key: (
device.online and device.sensors[key].error is False
), ),
AmazonBinarySensorEntityDescription(
key="beepingApplianceDetectionState",
translation_key="beeping_appliance_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="coughDetectionState",
translation_key="cough_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="dogBarkDetectionState",
translation_key="dog_bark_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="humanPresenceDetectionState",
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="waterSoundsDetectionState",
translation_key="water_sounds_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
), ),
) )
@@ -66,15 +94,6 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
# Replace unique id for "detectionState" binary sensor
await async_update_unique_id(
hass,
coordinator,
BINARY_SENSOR_DOMAIN,
"humanPresenceDetectionState",
"detectionState",
)
known_devices: set[str] = set() known_devices: set[str] = set()
def _check_device() -> None: def _check_device() -> None:
@@ -106,13 +125,3 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
return self.entity_description.is_on_fn( return self.entity_description.is_on_fn(
self.device, self.entity_description.key self.device, self.entity_description.key
) )
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
self.entity_description.is_available_fn(
self.device, self.entity_description.key
)
and super().available
)

View File

@@ -64,7 +64,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
data = await validate_input(self.hass, user_input) data = await validate_input(self.hass, user_input)
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except CannotAuthenticate: except (CannotAuthenticate, TypeError):
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except CannotRetrieveData: except CannotRetrieveData:
errors["base"] = "cannot_retrieve_data" errors["base"] = "cannot_retrieve_data"
@@ -112,7 +112,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
) )
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except CannotAuthenticate: except (CannotAuthenticate, TypeError):
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except CannotRetrieveData: except CannotRetrieveData:
errors["base"] = "cannot_retrieve_data" errors["base"] = "cannot_retrieve_data"

View File

@@ -68,7 +68,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
translation_key="cannot_retrieve_data_with_error", translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)}, translation_placeholders={"error": repr(err)},
) from err ) from err
except CannotAuthenticate as err: except (CannotAuthenticate, TypeError) as err:
raise ConfigEntryAuthFailed( raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_auth", translation_key="invalid_auth",

View File

@@ -60,5 +60,7 @@ def build_device_data(device: AmazonDevice) -> dict[str, Any]:
"online": device.online, "online": device.online,
"serial number": device.serial_number, "serial number": device.serial_number,
"software version": device.software_version, "software version": device.software_version,
"sensors": device.sensors, "do not disturb": device.do_not_disturb,
"response style": device.response_style,
"bluetooth state": device.bluetooth_state,
} }

View File

@@ -1,4 +1,44 @@
{ {
"entity": {
"binary_sensor": {
"bluetooth": {
"default": "mdi:bluetooth-off",
"state": {
"on": "mdi:bluetooth"
}
},
"baby_cry_detection": {
"default": "mdi:account-voice-off",
"state": {
"on": "mdi:account-voice"
}
},
"beeping_appliance_detection": {
"default": "mdi:bell-off",
"state": {
"on": "mdi:bell-ring"
}
},
"cough_detection": {
"default": "mdi:blur-off",
"state": {
"on": "mdi:blur"
}
},
"dog_bark_detection": {
"default": "mdi:dog-side-off",
"state": {
"on": "mdi:dog-side"
}
},
"water_sounds_detection": {
"default": "mdi:water-pump-off",
"state": {
"on": "mdi:water-pump"
}
}
}
},
"services": { "services": {
"send_sound": { "send_sound": {
"service": "mdi:cast-audio" "service": "mdi:cast-audio"

View File

@@ -7,6 +7,6 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioamazondevices"], "loggers": ["aioamazondevices"],
"quality_scale": "platinum", "quality_scale": "silver",
"requirements": ["aioamazondevices==6.2.6"] "requirements": ["aioamazondevices==6.0.0"]
} }

View File

@@ -31,9 +31,6 @@ class AmazonSensorEntityDescription(SensorEntityDescription):
"""Amazon Devices sensor entity description.""" """Amazon Devices sensor entity description."""
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online and device.sensors[key].error is False
)
SENSORS: Final = ( SENSORS: Final = (
@@ -102,13 +99,3 @@ class AmazonSensorEntity(AmazonEntity, SensorEntity):
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.device.sensors[self.entity_description.key].value return self.device.sensors[self.entity_description.key].value
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
self.entity_description.is_available_fn(
self.device, self.entity_description.key
)
and super().available
)

View File

@@ -58,6 +58,26 @@
} }
}, },
"entity": { "entity": {
"binary_sensor": {
"bluetooth": {
"name": "Bluetooth"
},
"baby_cry_detection": {
"name": "Baby crying"
},
"beeping_appliance_detection": {
"name": "Beeping appliance"
},
"cough_detection": {
"name": "Coughing"
},
"dog_bark_detection": {
"name": "Dog barking"
},
"water_sounds_detection": {
"name": "Water sounds"
}
},
"notify": { "notify": {
"speak": { "speak": {
"name": "Speak" "name": "Speak"

View File

@@ -8,17 +8,13 @@ from typing import TYPE_CHECKING, Any, Final
from aioamazondevices.api import AmazonDevice from aioamazondevices.api import AmazonDevice
from homeassistant.components.switch import ( from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
DOMAIN as SWITCH_DOMAIN,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity from .entity import AmazonEntity
from .utils import alexa_api_call, async_update_unique_id from .utils import alexa_api_call
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@@ -28,17 +24,16 @@ class AmazonSwitchEntityDescription(SwitchEntityDescription):
"""Alexa Devices switch entity description.""" """Alexa Devices switch entity description."""
is_on_fn: Callable[[AmazonDevice], bool] is_on_fn: Callable[[AmazonDevice], bool]
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: ( subkey: str
device.online and device.sensors[key].error is False
)
method: str method: str
SWITCHES: Final = ( SWITCHES: Final = (
AmazonSwitchEntityDescription( AmazonSwitchEntityDescription(
key="dnd", key="do_not_disturb",
subkey="AUDIO_PLAYER",
translation_key="do_not_disturb", translation_key="do_not_disturb",
is_on_fn=lambda device: bool(device.sensors["dnd"].value), is_on_fn=lambda _device: _device.do_not_disturb,
method="set_do_not_disturb", method="set_do_not_disturb",
), ),
) )
@@ -53,11 +48,6 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
# Replace unique id for "DND" switch and remove from Speaker Group
await async_update_unique_id(
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
)
known_devices: set[str] = set() known_devices: set[str] = set()
def _check_device() -> None: def _check_device() -> None:
@@ -69,7 +59,7 @@ async def async_setup_entry(
AmazonSwitchEntity(coordinator, serial_num, switch_desc) AmazonSwitchEntity(coordinator, serial_num, switch_desc)
for switch_desc in SWITCHES for switch_desc in SWITCHES
for serial_num in new_devices for serial_num in new_devices
if switch_desc.key in coordinator.data[serial_num].sensors if switch_desc.subkey in coordinator.data[serial_num].capabilities
) )
_check_device() _check_device()
@@ -104,13 +94,3 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if switch is on.""" """Return True if switch is on."""
return self.entity_description.is_on_fn(self.device) return self.entity_description.is_on_fn(self.device)
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
self.entity_description.is_available_fn(
self.device, self.entity_description.key
)
and super().available
)

View File

@@ -6,12 +6,9 @@ from typing import Any, Concatenate
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN from .const import DOMAIN
from .coordinator import AmazonDevicesCoordinator
from .entity import AmazonEntity from .entity import AmazonEntity
@@ -41,23 +38,3 @@ def alexa_api_call[_T: AmazonEntity, **_P](
) from err ) from err
return cmd_wrapper return cmd_wrapper
async def async_update_unique_id(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
domain: str,
old_key: str,
new_key: str,
) -> None:
"""Update unique id for entities created with old format."""
entity_registry = er.async_get(hass)
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{old_key}"
if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id):
_LOGGER.debug("Updating unique_id for %s", entity_id)
new_unique_id = unique_id.replace(old_key, new_key)
# Update the registry with the new unique_id
entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)

View File

@@ -1308,9 +1308,7 @@ class PipelineRun:
# instead of a full response. # instead of a full response.
all_targets_in_satellite_area = ( all_targets_in_satellite_area = (
self._get_all_targets_in_satellite_area( self._get_all_targets_in_satellite_area(
conversation_result.response, conversation_result.response, self._device_id
self._satellite_id,
self._device_id,
) )
) )
@@ -1339,62 +1337,39 @@ class PipelineRun:
return (speech, all_targets_in_satellite_area) return (speech, all_targets_in_satellite_area)
def _get_all_targets_in_satellite_area( def _get_all_targets_in_satellite_area(
self, self, intent_response: intent.IntentResponse, device_id: str | None
intent_response: intent.IntentResponse,
satellite_id: str | None,
device_id: str | None,
) -> bool: ) -> bool:
"""Return true if all targeted entities were in the same area as the device.""" """Return true if all targeted entities were in the same area as the device."""
if ( if (
intent_response.response_type != intent.IntentResponseType.ACTION_DONE (intent_response.response_type != intent.IntentResponseType.ACTION_DONE)
or not intent_response.matched_states or (not intent_response.matched_states)
or (not device_id)
):
return False
device_registry = dr.async_get(self.hass)
if (not (device := device_registry.async_get(device_id))) or (
not device.area_id
): ):
return False return False
entity_registry = er.async_get(self.hass) entity_registry = er.async_get(self.hass)
device_registry = dr.async_get(self.hass)
area_id: str | None = None
if (
satellite_id is not None
and (target_entity_entry := entity_registry.async_get(satellite_id))
is not None
):
area_id = target_entity_entry.area_id
device_id = target_entity_entry.device_id
if area_id is None:
if device_id is None:
return False
device_entry = device_registry.async_get(device_id)
if device_entry is None:
return False
area_id = device_entry.area_id
if area_id is None:
return False
for state in intent_response.matched_states: for state in intent_response.matched_states:
target_entity_entry = entity_registry.async_get(state.entity_id) entity = entity_registry.async_get(state.entity_id)
if target_entity_entry is None: if not entity:
return False return False
target_area_id = target_entity_entry.area_id if (entity_area_id := entity.area_id) is None:
if target_area_id is None: if (entity.device_id is None) or (
if target_entity_entry.device_id is None: (entity_device := device_registry.async_get(entity.device_id))
is None
):
return False return False
target_device_entry = device_registry.async_get( entity_area_id = entity_device.area_id
target_entity_entry.device_id
)
if target_device_entry is None:
return False
target_area_id = target_device_entry.area_id if entity_area_id != device.area_id:
if target_area_id != area_id:
return False return False
return True return True

View File

@@ -3,12 +3,16 @@ beolink_allstandby:
entity: entity:
integration: bang_olufsen integration: bang_olufsen
domain: media_player domain: media_player
device:
integration: bang_olufsen
beolink_expand: beolink_expand:
target: target:
entity: entity:
integration: bang_olufsen integration: bang_olufsen
domain: media_player domain: media_player
device:
integration: bang_olufsen
fields: fields:
all_discovered: all_discovered:
required: false required: false
@@ -33,6 +37,8 @@ beolink_join:
entity: entity:
integration: bang_olufsen integration: bang_olufsen
domain: media_player domain: media_player
device:
integration: bang_olufsen
fields: fields:
jid_options: jid_options:
collapsed: false collapsed: false
@@ -65,12 +71,16 @@ beolink_leave:
entity: entity:
integration: bang_olufsen integration: bang_olufsen
domain: media_player domain: media_player
device:
integration: bang_olufsen
beolink_unexpand: beolink_unexpand:
target: target:
entity: entity:
integration: bang_olufsen integration: bang_olufsen
domain: media_player domain: media_player
device:
integration: bang_olufsen
fields: fields:
jid_options: jid_options:
collapsed: false collapsed: false

View File

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

View File

@@ -25,27 +25,23 @@ from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
from .utils import async_client_session from .utils import async_client_session
DEFAULT_HOST = "192.168.1.252" DEFAULT_HOST = "192.168.1.252"
DEFAULT_PIN = "111111" DEFAULT_PIN = 111111
pin_regex = r"^[0-9]{4,10}$"
USER_SCHEMA = vol.Schema( USER_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex), vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int,
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
} }
) )
STEP_REAUTH_DATA_SCHEMA = vol.Schema( STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.positive_int})
{vol.Required(CONF_PIN): cv.matches_regex(pin_regex)}
)
STEP_RECONFIGURE = vol.Schema( STEP_RECONFIGURE = vol.Schema(
{ {
vol.Required(CONF_HOST): cv.string, vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port, vol.Required(CONF_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex), vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int,
} }
) )

View File

@@ -7,6 +7,6 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aiocomelit"], "loggers": ["aiocomelit"],
"quality_scale": "platinum", "quality_scale": "silver",
"requirements": ["aiocomelit==0.12.3"] "requirements": ["aiocomelit==0.12.3"]
} }

View File

@@ -32,7 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, entry,
options={**entry.options, CONF_SOURCE: source_entity_id}, options={**entry.options, CONF_SOURCE: source_entity_id},
) )
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload( entry.async_on_unload(
async_handle_source_entity_changes( async_handle_source_entity_changes(
@@ -47,9 +46,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
) )
await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,)) return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,))

View File

@@ -140,7 +140,6 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW
options_flow_reloads = True
VERSION = 1 VERSION = 1
MINOR_VERSION = 4 MINOR_VERSION = 4

View File

@@ -6,13 +6,12 @@ from typing import TYPE_CHECKING, Any, Protocol
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_DOMAIN, CONF_OPTIONS from homeassistant.const import CONF_DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.condition import ( from homeassistant.helpers.condition import (
Condition, Condition,
ConditionCheckerType, ConditionCheckerType,
ConditionConfig,
trace_condition_function, trace_condition_function,
) )
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@@ -56,40 +55,19 @@ class DeviceAutomationConditionProtocol(Protocol):
class DeviceCondition(Condition): class DeviceCondition(Condition):
"""Device condition.""" """Device condition."""
_hass: HomeAssistant def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
_config: ConfigType """Initialize condition."""
self._config = config
@classmethod self._hass = hass
async def async_validate_complete_config(
cls, hass: HomeAssistant, complete_config: ConfigType
) -> ConfigType:
"""Validate complete config."""
complete_config = await async_validate_device_automation_config(
hass,
complete_config,
cv.DEVICE_CONDITION_SCHEMA,
DeviceAutomationType.CONDITION,
)
# Since we don't want to migrate device conditions to a new format
# we just pass the entire config as options.
complete_config[CONF_OPTIONS] = complete_config.copy()
return complete_config
@classmethod @classmethod
async def async_validate_config( async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType: ) -> ConfigType:
"""Validate config. """Validate device condition config."""
return await async_validate_device_automation_config(
This is here just to satisfy the abstract class interface. It is never called. hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
""" )
raise NotImplementedError
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
self._hass = hass
assert config.options is not None
self._config = config.options
async def async_get_checker(self) -> condition.ConditionCheckerType: async def async_get_checker(self) -> condition.ConditionCheckerType:
"""Test a device condition.""" """Test a device condition."""

View File

@@ -69,9 +69,7 @@ class EcovacsMap(
await super().async_added_to_hass() await super().async_added_to_hass()
async def on_info(event: CachedMapInfoEvent) -> None: async def on_info(event: CachedMapInfoEvent) -> None:
for map_obj in event.maps: self._attr_extra_state_attributes["map_name"] = event.name
if map_obj.using:
self._attr_extra_state_attributes["map_name"] = map_obj.name
async def on_changed(event: MapChangedEvent) -> None: async def on_changed(event: MapChangedEvent) -> None:
self._attr_image_last_updated = event.when self._attr_image_last_updated = event.when

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==15.0.0"] "requirements": ["py-sucks==0.9.11", "deebot-client==14.0.0"]
} }

View File

@@ -3,15 +3,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from enum import IntEnum
import logging import logging
from typing import Any from typing import Any
from pyephember2.pyephember2 import ( from pyephember2.pyephember2 import (
EphEmber, EphEmber,
ZoneMode, ZoneMode,
boiler_state,
zone_current_temperature, zone_current_temperature,
zone_is_active,
zone_is_hotwater, zone_is_hotwater,
zone_mode, zone_mode,
zone_name, zone_name,
@@ -54,15 +53,6 @@ EPH_TO_HA_STATE = {
"OFF": HVACMode.OFF, "OFF": HVACMode.OFF,
} }
class EPHBoilerStates(IntEnum):
"""Boiler states for a zone given by the api."""
FIXME = 0
OFF = 1
ON = 2
HA_STATE_TO_EPH = {value: key for key, value in EPH_TO_HA_STATE.items()} HA_STATE_TO_EPH = {value: key for key, value in EPH_TO_HA_STATE.items()}
@@ -133,7 +123,7 @@ class EphEmberThermostat(ClimateEntity):
@property @property
def hvac_action(self) -> HVACAction: def hvac_action(self) -> HVACAction:
"""Return current HVAC action.""" """Return current HVAC action."""
if boiler_state(self._zone) == EPHBoilerStates.ON: if zone_is_active(self._zone):
return HVACAction.HEATING return HVACAction.HEATING
return HVACAction.IDLE return HVACAction.IDLE

View File

@@ -57,7 +57,6 @@ from .manager import async_replace_device
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
ERROR_INVALID_PASSWORD_AUTH = "invalid_auth"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
@@ -138,11 +137,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._password = "" self._password = ""
return await self._async_authenticate_or_add() return await self._async_authenticate_or_add()
if error == ERROR_INVALID_PASSWORD_AUTH or (
error is None and self._device_info and self._device_info.uses_password
):
return await self.async_step_authenticate()
if error is None and entry_data.get(CONF_NOISE_PSK): if error is None and entry_data.get(CONF_NOISE_PSK):
# Device was configured with encryption but now connects without it. # Device was configured with encryption but now connects without it.
# Check if it's the same device before offering to remove encryption. # Check if it's the same device before offering to remove encryption.
@@ -696,15 +690,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
cli = APIClient( cli = APIClient(
host, host,
port or DEFAULT_PORT, port or DEFAULT_PORT,
self._password or "", "",
zeroconf_instance=zeroconf_instance, zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk, noise_psk=noise_psk,
) )
try: try:
await cli.connect() await cli.connect()
self._device_info = await cli.device_info() self._device_info = await cli.device_info()
except InvalidAuthAPIError:
return ERROR_INVALID_PASSWORD_AUTH
except RequiresEncryptionAPIError: except RequiresEncryptionAPIError:
return ERROR_REQUIRES_ENCRYPTION_KEY return ERROR_REQUIRES_ENCRYPTION_KEY
except InvalidEncryptionKeyAPIError as ex: except InvalidEncryptionKeyAPIError as ex:

View File

@@ -372,9 +372,6 @@ class ESPHomeManager:
"""Subscribe to states and list entities on successful API login.""" """Subscribe to states and list entities on successful API login."""
try: try:
await self._on_connect() await self._on_connect()
except InvalidAuthAPIError as err:
_LOGGER.warning("Authentication failed for %s: %s", self.host, err)
await self._start_reauth_and_disconnect()
except APIConnectionError as err: except APIConnectionError as err:
_LOGGER.warning( _LOGGER.warning(
"Error getting setting up connection for %s: %s", self.host, err "Error getting setting up connection for %s: %s", self.host, err
@@ -644,14 +641,7 @@ class ESPHomeManager:
if self.reconnect_logic: if self.reconnect_logic:
await self.reconnect_logic.stop() await self.reconnect_logic.stop()
return return
await self._start_reauth_and_disconnect()
async def _start_reauth_and_disconnect(self) -> None:
"""Start reauth flow and stop reconnection attempts."""
self.entry.async_start_reauth(self.hass) self.entry.async_start_reauth(self.hass)
await self.cli.disconnect()
if self.reconnect_logic:
await self.reconnect_logic.stop()
async def _handle_dynamic_encryption_key( async def _handle_dynamic_encryption_key(
self, device_info: EsphomeDeviceInfo self, device_info: EsphomeDeviceInfo
@@ -1073,7 +1063,7 @@ def _async_register_service(
service_name, service_name,
{ {
"description": ( "description": (
f"Performs the action {service.name} of the node {device_info.name}" f"Calls the service {service.name} of the node {device_info.name}"
), ),
"fields": fields, "fields": fields,
}, },

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"], "mqtt": ["esphome/discover/#"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": [ "requirements": [
"aioesphomeapi==41.11.0", "aioesphomeapi==41.9.0",
"esphome-dashboard-api==1.3.0", "esphome-dashboard-api==1.3.0",
"bleak-esphome==3.3.0" "bleak-esphome==3.3.0"
], ],

View File

@@ -26,14 +26,11 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity):
super().__init__(coordinator) super().__init__(coordinator)
self._serial = serial self._serial = serial
self._camera_name = self.data["name"] self._camera_name = self.data["name"]
connections = set()
if mac_address := self.data["mac_address"]:
connections.add((CONNECTION_NETWORK_MAC, mac_address))
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial)}, identifiers={(DOMAIN, serial)},
connections=connections, connections={
(CONNECTION_NETWORK_MAC, self.data["mac_address"]),
},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=self.data["device_sub_category"], model=self.data["device_sub_category"],
name=self.data["name"], name=self.data["name"],
@@ -65,14 +62,11 @@ class EzvizBaseEntity(Entity):
self._serial = serial self._serial = serial
self.coordinator = coordinator self.coordinator = coordinator
self._camera_name = self.data["name"] self._camera_name = self.data["name"]
connections = set()
if mac_address := self.data["mac_address"]:
connections.add((CONNECTION_NETWORK_MAC, mac_address))
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial)}, identifiers={(DOMAIN, serial)},
connections=connections, connections={
(CONNECTION_NETWORK_MAC, self.data["mac_address"]),
},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=self.data["device_sub_category"], model=self.data["device_sub_category"],
name=self.data["name"], name=self.data["name"],

View File

@@ -10,6 +10,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Filter from a config entry.""" """Set up Filter from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True return True
@@ -17,3 +18,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Filter config entry.""" """Unload Filter config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -246,7 +246,6 @@ class FilterConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str: def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title.""" """Return config entry title."""

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250926.0"] "requirements": ["home-assistant-frontend==20250924.0"]
} }

View File

@@ -1,10 +1,8 @@
load_url: load_url:
fields: target:
device_id:
required: true
selector:
device: device:
integration: fully_kiosk integration: fully_kiosk
fields:
url: url:
example: "https://home-assistant.io" example: "https://home-assistant.io"
required: true required: true
@@ -12,12 +10,10 @@ load_url:
text: text:
set_config: set_config:
fields: target:
device_id:
required: true
selector:
device: device:
integration: fully_kiosk integration: fully_kiosk
fields:
key: key:
example: "motionSensitivity" example: "motionSensitivity"
required: true required: true
@@ -30,14 +26,12 @@ set_config:
text: text:
start_application: start_application:
target:
device:
integration: fully_kiosk
fields: fields:
application: application:
example: "de.ozerov.fully" example: "de.ozerov.fully"
required: true required: true
selector: selector:
text: text:
device_id:
required: true
selector:
device:
integration: fully_kiosk

View File

@@ -147,10 +147,6 @@
"name": "Load URL", "name": "Load URL",
"description": "Loads a URL on Fully Kiosk Browser.", "description": "Loads a URL on Fully Kiosk Browser.",
"fields": { "fields": {
"device_id": {
"name": "Device ID",
"description": "The target device for this action."
},
"url": { "url": {
"name": "[%key:common::config_flow::data::url%]", "name": "[%key:common::config_flow::data::url%]",
"description": "URL to load." "description": "URL to load."
@@ -161,10 +157,6 @@
"name": "Set configuration", "name": "Set configuration",
"description": "Sets a configuration parameter on Fully Kiosk Browser.", "description": "Sets a configuration parameter on Fully Kiosk Browser.",
"fields": { "fields": {
"device_id": {
"name": "[%key:component::fully_kiosk::services::load_url::fields::device_id::name%]",
"description": "[%key:component::fully_kiosk::services::load_url::fields::device_id::description%]"
},
"key": { "key": {
"name": "Key", "name": "Key",
"description": "Configuration parameter to set." "description": "Configuration parameter to set."
@@ -182,10 +174,6 @@
"application": { "application": {
"name": "Application", "name": "Application",
"description": "Package name of the application to start." "description": "Package name of the application to start."
},
"device_id": {
"name": "[%key:component::fully_kiosk::services::load_url::fields::device_id::name%]",
"description": "[%key:component::fully_kiosk::services::load_url::fields::device_id::description%]"
} }
} }
} }

View File

@@ -108,7 +108,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, entry,
options={**entry.options, CONF_HUMIDIFIER: source_entity_id}, options={**entry.options, CONF_HUMIDIFIER: source_entity_id},
) )
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload( entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the humidifer, # We use async_handle_source_entity_changes to track changes to the humidifer,
@@ -141,7 +140,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, entry,
options={**entry.options, CONF_SENSOR: data["entity_id"]}, options={**entry.options, CONF_SENSOR: data["entity_id"]},
) )
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload( entry.async_on_unload(
async_track_entity_registry_updated_event( async_track_entity_registry_updated_event(
@@ -150,6 +148,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
await hass.config_entries.async_forward_entry_setups(entry, (Platform.HUMIDIFIER,)) await hass.config_entries.async_forward_entry_setups(entry, (Platform.HUMIDIFIER,))
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True return True
@@ -187,6 +186,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms( return await hass.config_entries.async_unload_platforms(

View File

@@ -96,7 +96,6 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str: def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title.""" """Return config entry title."""

View File

@@ -35,7 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, entry,
options={**entry.options, CONF_HEATER: source_entity_id}, options={**entry.options, CONF_HEATER: source_entity_id},
) )
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload( entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the heater, but # We use async_handle_source_entity_changes to track changes to the heater, but
@@ -68,7 +67,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, entry,
options={**entry.options, CONF_SENSOR: data["entity_id"]}, options={**entry.options, CONF_SENSOR: data["entity_id"]},
) )
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload( entry.async_on_unload(
async_track_entity_registry_updated_event( async_track_entity_registry_updated_event(
@@ -77,6 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True return True
@@ -114,6 +113,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -104,7 +104,6 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str: def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title.""" """Return config entry title."""

View File

@@ -77,10 +77,10 @@ class GeniusDevice(GeniusEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update an entity's state data.""" """Update an entity's state data."""
if (state := self._device.data.get("_state")) and ( if "_state" in self._device.data: # only via v3 API
last_comms := state.get("lastComms") self._last_comms = dt_util.utc_from_timestamp(
) is not None: # only via v3 API self._device.data["_state"]["lastComms"]
self._last_comms = dt_util.utc_from_timestamp(last_comms) )
class GeniusZone(GeniusEntity): class GeniusZone(GeniusEntity):

View File

@@ -1,5 +1,7 @@
set_vacation: set_vacation:
target: target:
device:
integration: google_mail
entity: entity:
integration: google_mail integration: google_mail
fields: fields:

View File

@@ -141,9 +141,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups( await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["group_type"],) entry, (entry.options["group_type"],)
) )
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms( return await hass.config_entries.async_unload_platforms(

View File

@@ -329,7 +329,6 @@ class GroupConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW
options_flow_reloads = True
@callback @callback
def async_config_entry_title(self, options: Mapping[str, Any]) -> str: def async_config_entry_title(self, options: Mapping[str, Any]) -> str:

View File

@@ -65,7 +65,6 @@ async def async_setup_entry(
entry, entry,
options={**entry.options, CONF_ENTITY_ID: source_entity_id}, options={**entry.options, CONF_ENTITY_ID: source_entity_id},
) )
hass.config_entries.async_schedule_reload(entry.entry_id)
async def source_entity_removed() -> None: async def source_entity_removed() -> None:
# The source entity has been removed, we remove the config entry because # The source entity has been removed, we remove the config entry because
@@ -87,6 +86,7 @@ async def async_setup_entry(
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True return True
@@ -130,3 +130,8 @@ async def async_unload_entry(
) -> bool: ) -> bool:
"""Unload History stats config entry.""" """Unload History stats config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -162,7 +162,6 @@ class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str: def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title.""" """Return config entry title."""

View File

@@ -22,6 +22,6 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aiohomeconnect"], "loggers": ["aiohomeconnect"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.20.0"], "requirements": ["aiohomeconnect==0.19.0"],
"zeroconf": ["_homeconnect._tcp.local."] "zeroconf": ["_homeconnect._tcp.local."]
} }

View File

@@ -32,12 +32,15 @@ set_location:
stop: stop:
toggle: toggle:
target: target:
entity: {}
turn_on: turn_on:
target: target:
entity: {}
turn_off: turn_off:
target: target:
entity: {}
update_entity: update_entity:
fields: fields:
@@ -50,6 +53,8 @@ update_entity:
reload_custom_templates: reload_custom_templates:
reload_config_entry: reload_config_entry:
target: target:
entity: {}
device: {}
fields: fields:
entry_id: entry_id:
advanced: true advanced: true

View File

@@ -3,9 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from email.message import Message
import logging import logging
from typing import Any
from aioimaplib import IMAP4_SSL, AioImapException, Response from aioimaplib import IMAP4_SSL, AioImapException, Response
import voluptuous as vol import voluptuous as vol
@@ -35,7 +33,6 @@ from .coordinator import (
ImapPollingDataUpdateCoordinator, ImapPollingDataUpdateCoordinator,
ImapPushDataUpdateCoordinator, ImapPushDataUpdateCoordinator,
connect_to_server, connect_to_server,
get_parts,
) )
from .errors import InvalidAuth, InvalidFolder from .errors import InvalidAuth, InvalidFolder
@@ -43,7 +40,6 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
CONF_ENTRY = "entry" CONF_ENTRY = "entry"
CONF_SEEN = "seen" CONF_SEEN = "seen"
CONF_PART = "part"
CONF_UID = "uid" CONF_UID = "uid"
CONF_TARGET_FOLDER = "target_folder" CONF_TARGET_FOLDER = "target_folder"
@@ -68,11 +64,6 @@ SERVICE_MOVE_SCHEMA = _SERVICE_UID_SCHEMA.extend(
) )
SERVICE_DELETE_SCHEMA = _SERVICE_UID_SCHEMA SERVICE_DELETE_SCHEMA = _SERVICE_UID_SCHEMA
SERVICE_FETCH_TEXT_SCHEMA = _SERVICE_UID_SCHEMA SERVICE_FETCH_TEXT_SCHEMA = _SERVICE_UID_SCHEMA
SERVICE_FETCH_PART_SCHEMA = _SERVICE_UID_SCHEMA.extend(
{
vol.Required(CONF_PART): cv.string,
}
)
type ImapConfigEntry = ConfigEntry[ImapDataUpdateCoordinator] type ImapConfigEntry = ConfigEntry[ImapDataUpdateCoordinator]
@@ -225,14 +216,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
translation_placeholders={"error": str(exc)}, translation_placeholders={"error": str(exc)},
) from exc ) from exc
raise_on_error(response, "fetch_failed") raise_on_error(response, "fetch_failed")
# Index 1 of of the response lines contains the bytearray with the message data
message = ImapMessage(response.lines[1]) message = ImapMessage(response.lines[1])
await client.close() await client.close()
return { return {
"text": message.text, "text": message.text,
"sender": message.sender, "sender": message.sender,
"subject": message.subject, "subject": message.subject,
"parts": get_parts(message.email_message),
"uid": uid, "uid": uid,
} }
@@ -244,73 +233,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
supports_response=SupportsResponse.ONLY, supports_response=SupportsResponse.ONLY,
) )
async def async_fetch_part(call: ServiceCall) -> ServiceResponse:
"""Process fetch email part service and return content."""
@callback
def get_message_part(message: Message, part_key: str) -> Message:
part: Message | Any = message
for index in part_key.split(","):
sub_parts = part.get_payload()
try:
assert isinstance(sub_parts, list)
part = sub_parts[int(index)]
except (AssertionError, ValueError, IndexError) as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_part_index",
) from exc
return part
entry_id: str = call.data[CONF_ENTRY]
uid: str = call.data[CONF_UID]
part_key: str = call.data[CONF_PART]
_LOGGER.debug(
"Fetch part %s for message %s. Entry: %s",
part_key,
uid,
entry_id,
)
client = await async_get_imap_client(hass, entry_id)
try:
response = await client.fetch(uid, "BODY.PEEK[]")
except (TimeoutError, AioImapException) as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="imap_server_fail",
translation_placeholders={"error": str(exc)},
) from exc
raise_on_error(response, "fetch_failed")
# Index 1 of of the response lines contains the bytearray with the message data
message = ImapMessage(response.lines[1])
await client.close()
part_data = get_message_part(message.email_message, part_key)
part_data_content = part_data.get_payload(decode=False)
try:
assert isinstance(part_data_content, str)
except AssertionError as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_part_index",
) from exc
return {
"part_data": part_data_content,
"content_type": part_data.get_content_type(),
"content_transfer_encoding": part_data.get("Content-Transfer-Encoding"),
"filename": part_data.get_filename(),
"part": part_key,
"uid": uid,
}
hass.services.async_register(
DOMAIN,
"fetch_part",
async_fetch_part,
SERVICE_FETCH_PART_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
return True return True

View File

@@ -21,7 +21,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
CONTENT_TYPE_TEXT_PLAIN, CONTENT_TYPE_TEXT_PLAIN,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ( from homeassistant.exceptions import (
ConfigEntryAuthFailed, ConfigEntryAuthFailed,
ConfigEntryError, ConfigEntryError,
@@ -209,28 +209,6 @@ class ImapMessage:
return str(self.email_message.get_payload()) return str(self.email_message.get_payload())
@callback
def get_parts(message: Message, prefix: str | None = None) -> dict[str, Any]:
"""Return information about the parts of a multipart message."""
parts: dict[str, Any] = {}
if not message.is_multipart():
return {}
for index, part in enumerate(message.get_payload(), 0):
if TYPE_CHECKING:
assert isinstance(part, Message)
key = f"{prefix},{index}" if prefix else f"{index}"
if part.is_multipart():
parts |= get_parts(part, key)
continue
parts[key] = {"content_type": part.get_content_type()}
if filename := part.get_filename():
parts[key]["filename"] = filename
if content_transfer_encoding := part.get("Content-Transfer-Encoding"):
parts[key]["content_transfer_encoding"] = content_transfer_encoding
return parts
class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"""Base class for imap client.""" """Base class for imap client."""
@@ -297,7 +275,6 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"sender": message.sender, "sender": message.sender,
"subject": message.subject, "subject": message.subject,
"uid": last_message_uid, "uid": last_message_uid,
"parts": get_parts(message.email_message),
} }
data.update({key: getattr(message, key) for key in self._event_data_keys}) data.update({key: getattr(message, key) for key in self._event_data_keys})
if self.custom_event_template is not None: if self.custom_event_template is not None:

View File

@@ -21,9 +21,6 @@
}, },
"fetch": { "fetch": {
"service": "mdi:email-sync-outline" "service": "mdi:email-sync-outline"
},
"fetch_part": {
"service": "mdi:email-sync-outline"
} }
} }
} }

View File

@@ -56,22 +56,3 @@ fetch:
example: "12" example: "12"
selector: selector:
text: text:
fetch_part:
fields:
entry:
required: true
selector:
config_entry:
integration: "imap"
uid:
required: true
example: "12"
selector:
text:
part:
required: true
example: "0,1"
selector:
text:

View File

@@ -84,9 +84,6 @@
"imap_server_fail": { "imap_server_fail": {
"message": "The IMAP server failed to connect: {error}." "message": "The IMAP server failed to connect: {error}."
}, },
"invalid_part_index": {
"message": "Invalid part index."
},
"seen_failed": { "seen_failed": {
"message": "Marking message as seen failed with \"{error}\"." "message": "Marking message as seen failed with \"{error}\"."
} }
@@ -151,24 +148,6 @@
} }
} }
}, },
"fetch_part": {
"name": "Fetch message part",
"description": "Fetches a message part or attachment from an email message.",
"fields": {
"entry": {
"name": "[%key:component::imap::services::fetch::fields::entry::name%]",
"description": "[%key:component::imap::services::fetch::fields::entry::description%]"
},
"uid": {
"name": "[%key:component::imap::services::fetch::fields::uid::name%]",
"description": "[%key:component::imap::services::fetch::fields::uid::description%]"
},
"part": {
"name": "Part",
"description": "The message part index."
}
}
},
"seen": { "seen": {
"name": "Mark message as seen", "name": "Mark message as seen",
"description": "Marks an email as seen.", "description": "Marks an email as seen.",

View File

@@ -142,7 +142,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
) )
coordinators = LaMarzoccoRuntimeData( coordinators = LaMarzoccoRuntimeData(
LaMarzoccoConfigUpdateCoordinator(hass, entry, device, cloud_client), LaMarzoccoConfigUpdateCoordinator(hass, entry, device),
LaMarzoccoSettingsUpdateCoordinator(hass, entry, device), LaMarzoccoSettingsUpdateCoordinator(hass, entry, device),
LaMarzoccoScheduleUpdateCoordinator(hass, entry, device), LaMarzoccoScheduleUpdateCoordinator(hass, entry, device),
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device), LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),

View File

@@ -8,7 +8,7 @@ from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from pylamarzocco import LaMarzoccoCloudClient, LaMarzoccoMachine from pylamarzocco import LaMarzoccoMachine
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN from .const import DOMAIN
SCAN_INTERVAL = timedelta(seconds=60) SCAN_INTERVAL = timedelta(seconds=15)
SETTINGS_UPDATE_INTERVAL = timedelta(hours=8) SETTINGS_UPDATE_INTERVAL = timedelta(hours=8)
SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=30) SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=30)
STATISTICS_UPDATE_INTERVAL = timedelta(minutes=15) STATISTICS_UPDATE_INTERVAL = timedelta(minutes=15)
@@ -51,7 +51,6 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
hass: HomeAssistant, hass: HomeAssistant,
entry: LaMarzoccoConfigEntry, entry: LaMarzoccoConfigEntry,
device: LaMarzoccoMachine, device: LaMarzoccoMachine,
cloud_client: LaMarzoccoCloudClient | None = None,
) -> None: ) -> None:
"""Initialize coordinator.""" """Initialize coordinator."""
super().__init__( super().__init__(
@@ -62,7 +61,6 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
update_interval=self._default_update_interval, update_interval=self._default_update_interval,
) )
self.device = device self.device = device
self.cloud_client = cloud_client
async def _async_update_data(self) -> None: async def _async_update_data(self) -> None:
"""Do the data update.""" """Do the data update."""
@@ -87,17 +85,11 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator): class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Class to handle fetching data from the La Marzocco API centrally.""" """Class to handle fetching data from the La Marzocco API centrally."""
cloud_client: LaMarzoccoCloudClient
async def _internal_async_update_data(self) -> None: async def _internal_async_update_data(self) -> None:
"""Fetch data from API endpoint.""" """Fetch data from API endpoint."""
# ensure token stays valid; does nothing if token is still valid
await self.cloud_client.async_get_access_token()
if self.device.websocket.connected: if self.device.websocket.connected:
return return
await self.device.get_dashboard() await self.device.get_dashboard()
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict()) _LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pylamarzocco"], "loggers": ["pylamarzocco"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pylamarzocco==2.1.1"] "requirements": ["pylamarzocco==2.1.0"]
} }

View File

@@ -12,7 +12,7 @@ from homeassistant.components.number import (
NumberEntityDescription, NumberEntityDescription,
NumberMode, NumberMode,
) )
from homeassistant.const import PRECISION_WHOLE, EntityCategory, UnitOfTime from homeassistant.const import PRECISION_WHOLE, EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -72,7 +72,6 @@ NUMBERS: tuple[LetPotNumberEntityDescription, ...] = (
LetPotNumberEntityDescription( LetPotNumberEntityDescription(
key="plant_days", key="plant_days",
translation_key="plant_days", translation_key="plant_days",
native_unit_of_measurement=UnitOfTime.DAYS,
value_fn=lambda coordinator: coordinator.data.plant_days, value_fn=lambda coordinator: coordinator.data.plant_days,
set_value_fn=( set_value_fn=(
lambda device_client, serial, value: device_client.set_plant_days( lambda device_client, serial, value: device_client.set_plant_days(

View File

@@ -54,7 +54,8 @@
"name": "Light brightness" "name": "Light brightness"
}, },
"plant_days": { "plant_days": {
"name": "Plants age" "name": "Plants age",
"unit_of_measurement": "days"
} }
}, },
"select": { "select": {

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor", "documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["librehardwaremonitor-api==1.4.0"] "requirements": ["librehardwaremonitor-api==1.3.1"]
} }

View File

@@ -28,7 +28,7 @@ rules:
docs-configuration-parameters: docs-configuration-parameters:
status: done status: done
comment: No options to configure comment: No options to configure
docs-installation-parameters: done docs-installation-parameters: todo
entity-unavailable: todo entity-unavailable: todo
integration-owner: done integration-owner: done
log-when-unavailable: todo log-when-unavailable: todo

View File

@@ -22,6 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True return True
@@ -29,3 +30,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Local file config entry.""" """Unload Local file config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -65,7 +65,6 @@ class LocalFileConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str: def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title.""" """Return config entry title."""

View File

@@ -1,5 +1,7 @@
set_hold_time: set_hold_time:
target: target:
device:
integration: lyric
entity: entity:
integration: lyric integration: lyric
domain: climate domain: climate

View File

@@ -7,9 +7,8 @@ from aiomealie import MealieAuthenticationError, MealieClient, MealieConnectionE
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_PORT, CONF_VERIFY_SSL from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from .const import DOMAIN, LOGGER, MIN_REQUIRED_MEALIE_VERSION from .const import DOMAIN, LOGGER, MIN_REQUIRED_MEALIE_VERSION
from .utils import create_version from .utils import create_version
@@ -26,21 +25,13 @@ REAUTH_SCHEMA = vol.Schema(
vol.Required(CONF_API_TOKEN): str, vol.Required(CONF_API_TOKEN): str,
} }
) )
DISCOVERY_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_TOKEN): str,
}
)
class MealieConfigFlow(ConfigFlow, domain=DOMAIN): class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
"""Mealie config flow.""" """Mealie config flow."""
VERSION = 1
host: str | None = None host: str | None = None
verify_ssl: bool = True verify_ssl: bool = True
_hassio_discovery: dict[str, Any] | None = None
async def check_connection( async def check_connection(
self, api_token: str self, api_token: str
@@ -152,59 +143,3 @@ class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=USER_SCHEMA, data_schema=USER_SCHEMA,
errors=errors, errors=errors,
) )
async def async_step_hassio(
self, discovery_info: HassioServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a Mealie add-on.
This flow is triggered by the discovery component.
"""
await self._async_handle_discovery_without_unique_id()
self._hassio_discovery = discovery_info.config
return await self.async_step_hassio_confirm()
async def async_step_hassio_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm Supervisor discovery and prompt for API token."""
if user_input is None:
return await self._show_hassio_form()
assert self._hassio_discovery
self.host = (
f"{self._hassio_discovery[CONF_HOST]}:{self._hassio_discovery[CONF_PORT]}"
)
self.verify_ssl = True
errors, user_id = await self.check_connection(
user_input[CONF_API_TOKEN],
)
if not errors:
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Mealie",
data={
CONF_HOST: self.host,
CONF_API_TOKEN: user_input[CONF_API_TOKEN],
CONF_VERIFY_SSL: self.verify_ssl,
},
)
return await self._show_hassio_form(errors)
async def _show_hassio_form(
self, errors: dict[str, str] | None = None
) -> ConfigFlowResult:
"""Show the Hass.io confirmation form to the user."""
assert self._hassio_discovery
return self.async_show_form(
step_id="hassio_confirm",
data_schema=DISCOVERY_SCHEMA,
description_placeholders={"addon": self._hassio_discovery["addon"]},
errors=errors or {},
)

View File

@@ -39,14 +39,8 @@ rules:
# Gold # Gold
devices: done devices: done
diagnostics: done diagnostics: done
discovery-update-info: discovery-update-info: todo
status: exempt discovery: todo
comment: |
This integration will only discover a Mealie addon that is local, not on the network.
discovery:
status: done
comment: |
The integration will discover a Mealie addon posting a discovery message.
docs-data-update: done docs-data-update: done
docs-examples: done docs-examples: done
docs-known-limitations: todo docs-known-limitations: todo

View File

@@ -39,16 +39,6 @@
"api_token": "[%key:component::mealie::common::data_description_api_token%]", "api_token": "[%key:component::mealie::common::data_description_api_token%]",
"verify_ssl": "[%key:component::mealie::common::data_description_verify_ssl%]" "verify_ssl": "[%key:component::mealie::common::data_description_verify_ssl%]"
} }
},
"hassio_confirm": {
"title": "Mealie via Home Assistant add-on",
"description": "Do you want to configure Home Assistant to connect to the Mealie instance provided by the add-on: {addon}?",
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
},
"data_description": {
"api_token": "[%key:component::mealie::common::data_description_api_token%]"
}
} }
}, },
"error": { "error": {
@@ -60,7 +50,6 @@
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_account": "You have to use the same account that was used to configure the integration." "wrong_account": "You have to use the same account that was used to configure the integration."

View File

@@ -39,7 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, entry,
options={**entry.options, CONF_INDOOR_HUMIDITY: source_entity_id}, options={**entry.options, CONF_INDOOR_HUMIDITY: source_entity_id},
) )
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload( entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the humidity # We use async_handle_source_entity_changes to track changes to the humidity
@@ -80,7 +79,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, entry,
options={**entry.options, temp_sensor: data["entity_id"]}, options={**entry.options, temp_sensor: data["entity_id"]},
) )
hass.config_entries.async_schedule_reload(entry.entry_id)
return async_sensor_updated return async_sensor_updated
@@ -91,6 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True return True
@@ -100,6 +99,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry.""" """Migrate old entry."""

View File

@@ -100,7 +100,6 @@ class MoldIndicatorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW
options_flow_reloads = True
VERSION = 1 VERSION = 1
MINOR_VERSION = 2 MINOR_VERSION = 2

View File

@@ -1,7 +1,8 @@
set_text_overlay: set_text_overlay:
target: target:
device:
integration: motioneye
entity: entity:
domain: camera
integration: motioneye integration: motioneye
fields: fields:
left_text: left_text:
@@ -47,8 +48,9 @@ set_text_overlay:
action: action:
target: target:
device:
integration: motioneye
entity: entity:
domain: camera
integration: motioneye integration: motioneye
fields: fields:
action: action:
@@ -86,6 +88,7 @@ action:
snapshot: snapshot:
target: target:
entity: device:
domain: camera integration: motioneye
entity:
integration: motioneye integration: motioneye

View File

@@ -38,7 +38,10 @@ from homeassistant.core import (
get_hassjob_callable_job_type, get_hassjob_callable_job_type,
) )
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.importlib import async_import_module from homeassistant.helpers.importlib import async_import_module
from homeassistant.helpers.start import async_at_started from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@@ -71,6 +74,7 @@ from .const import (
DEFAULT_WS_PATH, DEFAULT_WS_PATH,
DOMAIN, DOMAIN,
MQTT_CONNECTION_STATE, MQTT_CONNECTION_STATE,
MQTT_PROCESSED_SUBSCRIPTIONS,
PROTOCOL_5, PROTOCOL_5,
PROTOCOL_31, PROTOCOL_31,
TRANSPORT_WEBSOCKETS, TRANSPORT_WEBSOCKETS,
@@ -109,6 +113,7 @@ INITIAL_SUBSCRIBE_COOLDOWN = 0.5
SUBSCRIBE_COOLDOWN = 0.1 SUBSCRIBE_COOLDOWN = 0.1
UNSUBSCRIBE_COOLDOWN = 0.1 UNSUBSCRIBE_COOLDOWN = 0.1
TIMEOUT_ACK = 10 TIMEOUT_ACK = 10
SUBSCRIBE_TIMEOUT = 10
RECONNECT_INTERVAL_SECONDS = 10 RECONNECT_INTERVAL_SECONDS = 10
MAX_WILDCARD_SUBSCRIBES_PER_CALL = 1 MAX_WILDCARD_SUBSCRIBES_PER_CALL = 1
@@ -191,11 +196,47 @@ async def async_subscribe(
msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None],
qos: int = DEFAULT_QOS, qos: int = DEFAULT_QOS,
encoding: str | None = DEFAULT_ENCODING, encoding: str | None = DEFAULT_ENCODING,
wait: bool = False,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Subscribe to an MQTT topic. """Subscribe to an MQTT topic.
Call the return value to unsubscribe. Call the return value to unsubscribe.
""" """
subscription_complete: asyncio.Future[None]
async def _sync_mqtt_subscribe(subscriptions: list[tuple[str, int]]) -> None:
if (topic, qos) not in subscriptions:
return
subscription_complete.set_result(None)
def _async_timeout_subscribe() -> None:
if not subscription_complete.done():
subscription_complete.set_exception(TimeoutError)
if (
wait
and DATA_MQTT in hass.data
and not hass.data[DATA_MQTT].client._matching_subscriptions(topic) # noqa: SLF001
):
subscription_complete = hass.loop.create_future()
dispatcher = async_dispatcher_connect(
hass, MQTT_PROCESSED_SUBSCRIPTIONS, _sync_mqtt_subscribe
)
subscribe_callback = async_subscribe_internal(
hass, topic, msg_callback, qos, encoding
)
try:
hass.loop.call_later(SUBSCRIBE_TIMEOUT, _async_timeout_subscribe)
await subscription_complete
except TimeoutError as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="subscribe_timeout",
) from exc
finally:
dispatcher()
return subscribe_callback
return async_subscribe_internal(hass, topic, msg_callback, qos, encoding) return async_subscribe_internal(hass, topic, msg_callback, qos, encoding)
@@ -963,6 +1004,7 @@ class MQTT:
self._last_subscribe = time.monotonic() self._last_subscribe = time.monotonic()
await self._async_wait_for_mid_or_raise(mid, result) await self._async_wait_for_mid_or_raise(mid, result)
async_dispatcher_send(self.hass, MQTT_PROCESSED_SUBSCRIPTIONS, chunk_list)
async def _async_perform_unsubscribes(self) -> None: async def _async_perform_unsubscribes(self) -> None:
"""Perform pending MQTT client unsubscribes.""" """Perform pending MQTT client unsubscribes."""

View File

@@ -51,10 +51,7 @@ from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS, DEVICE_CLASS_UNITS,
STATE_CLASS_UNITS, STATE_CLASS_UNITS,
SensorDeviceClass, SensorDeviceClass,
) SensorStateClass,
from homeassistant.components.sensor.helpers import (
create_sensor_device_class_select_selector,
create_sensor_state_class_select_selector,
) )
from homeassistant.components.switch import SwitchDeviceClass from homeassistant.components.switch import SwitchDeviceClass
from homeassistant.config_entries import ( from homeassistant.config_entries import (
@@ -706,6 +703,14 @@ SCALE_SELECTOR = NumberSelector(
step=1, step=1,
) )
) )
SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorDeviceClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class_sensor",
sort=True,
)
)
SENSOR_ENTITY_CATEGORY_SELECTOR = SelectSelector( SENSOR_ENTITY_CATEGORY_SELECTOR = SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=[EntityCategory.DIAGNOSTIC.value], options=[EntityCategory.DIAGNOSTIC.value],
@@ -714,6 +719,13 @@ SENSOR_ENTITY_CATEGORY_SELECTOR = SelectSelector(
sort=True, sort=True,
) )
) )
SENSOR_STATE_CLASS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorStateClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key=CONF_STATE_CLASS,
)
)
SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=[platform.value for platform in VALID_COLOR_MODES], options=[platform.value for platform in VALID_COLOR_MODES],
@@ -1272,12 +1284,10 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
Platform.NOTIFY.value: {}, Platform.NOTIFY.value: {},
Platform.SENSOR.value: { Platform.SENSOR.value: {
CONF_DEVICE_CLASS: PlatformField( CONF_DEVICE_CLASS: PlatformField(
selector=create_sensor_device_class_select_selector(), selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False
required=False,
), ),
CONF_STATE_CLASS: PlatformField( CONF_STATE_CLASS: PlatformField(
selector=create_sensor_state_class_select_selector(), selector=SENSOR_STATE_CLASS_SELECTOR, required=False
required=False,
), ),
CONF_UNIT_OF_MEASUREMENT: PlatformField( CONF_UNIT_OF_MEASUREMENT: PlatformField(
selector=unit_of_measurement_selector, selector=unit_of_measurement_selector,

View File

@@ -370,6 +370,7 @@ DOMAIN = "mqtt"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
MQTT_CONNECTION_STATE = "mqtt_connection_state" MQTT_CONNECTION_STATE = "mqtt_connection_state"
MQTT_PROCESSED_SUBSCRIPTIONS = "mqtt_processed_subscriptions"
PAYLOAD_EMPTY_JSON = "{}" PAYLOAD_EMPTY_JSON = "{}"
PAYLOAD_NONE = "None" PAYLOAD_NONE = "None"

View File

@@ -1200,6 +1200,67 @@
"window": "[%key:component::cover::entity_component::window::name%]" "window": "[%key:component::cover::entity_component::window::name%]"
} }
}, },
"device_class_sensor": {
"options": {
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
"current": "[%key:component::sensor::entity_component::current::name%]",
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
"date": "[%key:component::sensor::entity_component::date::name%]",
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"enum": "Enumeration",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
"water": "[%key:component::sensor::entity_component::water::name%]",
"weight": "[%key:component::sensor::entity_component::weight::name%]",
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
},
"device_class_switch": { "device_class_switch": {
"options": { "options": {
"outlet": "[%key:component::switch::entity_component::outlet::name%]", "outlet": "[%key:component::switch::entity_component::outlet::name%]",
@@ -1261,6 +1322,14 @@
"custom": "Custom" "custom": "Custom"
} }
}, },
"state_class": {
"options": {
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
"measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]",
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
}
},
"supported_color_modes": { "supported_color_modes": {
"options": { "options": {
"onoff": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::onoff%]", "onoff": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::onoff%]",

View File

@@ -2,8 +2,10 @@
"domain": "mvglive", "domain": "mvglive",
"name": "MVG", "name": "MVG",
"codeowners": [], "codeowners": [],
"disabled": "This integration is disabled because it uses non-open source code to operate.",
"documentation": "https://www.home-assistant.io/integrations/mvglive", "documentation": "https://www.home-assistant.io/integrations/mvglive",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["MVG"], "loggers": ["MVGLive"],
"requirements": ["mvg==1.4.0"] "quality_scale": "legacy",
"requirements": ["PyMVGLive==1.1.4"]
} }

View File

@@ -1,14 +1,13 @@
"""Support for departure information for public transport in Munich.""" """Support for departure information for public transport in Munich."""
# mypy: ignore-errors
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy from copy import deepcopy
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any
from mvg import MvgApi, MvgApiError, TransportType import MVGLive
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@@ -20,7 +19,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -46,14 +44,11 @@ ICONS = {
"SEV": "mdi:checkbox-blank-circle-outline", "SEV": "mdi:checkbox-blank-circle-outline",
"-": "mdi:clock", "-": "mdi:clock",
} }
ATTRIBUTION = "Data provided by MVG-live.de"
ATTRIBUTION = "Data provided by mvg.de"
SCAN_INTERVAL = timedelta(seconds=30) SCAN_INTERVAL = timedelta(seconds=30)
PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
cv.deprecated(CONF_DIRECTIONS),
SENSOR_PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_NEXT_DEPARTURE): [ vol.Required(CONF_NEXT_DEPARTURE): [
{ {
@@ -70,22 +65,22 @@ PLATFORM_SCHEMA = vol.All(
} }
] ]
} }
),
) )
async def async_setup_platform( def setup_platform(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config: ConfigType,
add_entities: AddEntitiesCallback, add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the MVGLive sensor.""" """Set up the MVGLive sensor."""
sensors = [ add_entities(
(
MVGLiveSensor( MVGLiveSensor(
hass,
nextdeparture.get(CONF_STATION), nextdeparture.get(CONF_STATION),
nextdeparture.get(CONF_DESTINATIONS), nextdeparture.get(CONF_DESTINATIONS),
nextdeparture.get(CONF_DIRECTIONS),
nextdeparture.get(CONF_LINES), nextdeparture.get(CONF_LINES),
nextdeparture.get(CONF_PRODUCTS), nextdeparture.get(CONF_PRODUCTS),
nextdeparture.get(CONF_TIMEOFFSET), nextdeparture.get(CONF_TIMEOFFSET),
@@ -93,8 +88,9 @@ async def async_setup_platform(
nextdeparture.get(CONF_NAME), nextdeparture.get(CONF_NAME),
) )
for nextdeparture in config[CONF_NEXT_DEPARTURE] for nextdeparture in config[CONF_NEXT_DEPARTURE]
] ),
add_entities(sensors, True) True,
)
class MVGLiveSensor(SensorEntity): class MVGLiveSensor(SensorEntity):
@@ -104,38 +100,38 @@ class MVGLiveSensor(SensorEntity):
def __init__( def __init__(
self, self,
hass: HomeAssistant, station,
station_name,
destinations, destinations,
directions,
lines, lines,
products, products,
timeoffset, timeoffset,
number, number,
name, name,
) -> None: ):
"""Initialize the sensor.""" """Initialize the sensor."""
self._station = station
self._name = name self._name = name
self._station_name = station_name
self.data = MVGLiveData( self.data = MVGLiveData(
hass, station_name, destinations, lines, products, timeoffset, number station, destinations, directions, lines, products, timeoffset, number
) )
self._state = None self._state = None
self._icon = ICONS["-"] self._icon = ICONS["-"]
@property @property
def name(self) -> str | None: def name(self):
"""Return the name of the sensor.""" """Return the name of the sensor."""
if self._name: if self._name:
return self._name return self._name
return self._station_name return self._station
@property @property
def native_value(self) -> str | None: def native_value(self):
"""Return the next departure time.""" """Return the next departure time."""
return self._state return self._state
@property @property
def extra_state_attributes(self) -> Mapping[str, Any] | None: def extra_state_attributes(self):
"""Return the state attributes.""" """Return the state attributes."""
if not (dep := self.data.departures): if not (dep := self.data.departures):
return None return None
@@ -144,114 +140,88 @@ class MVGLiveSensor(SensorEntity):
return attr return attr
@property @property
def icon(self) -> str | None: def icon(self):
"""Icon to use in the frontend, if any.""" """Icon to use in the frontend, if any."""
return self._icon return self._icon
@property @property
def native_unit_of_measurement(self) -> str | None: def native_unit_of_measurement(self):
"""Return the unit this state is expressed in.""" """Return the unit this state is expressed in."""
return UnitOfTime.MINUTES return UnitOfTime.MINUTES
async def async_update(self) -> None: def update(self) -> None:
"""Get the latest data and update the state.""" """Get the latest data and update the state."""
await self.data.update() self.data.update()
if not self.data.departures: if not self.data.departures:
self._state = None self._state = "-"
self._icon = ICONS["-"] self._icon = ICONS["-"]
else: else:
self._state = self.data.departures[0].get("time_in_mins", "-") self._state = self.data.departures[0].get("time", "-")
self._icon = self.data.departures[0].get("icon", ICONS["-"]) self._icon = ICONS[self.data.departures[0].get("product", "-")]
def _get_minutes_until_departure(departure_time: int) -> int:
"""Calculate the time difference in minutes between the current time and a given departure time.
Args:
departure_time: Unix timestamp of the departure time, in seconds.
Returns:
The time difference in minutes, as an integer.
"""
current_time = dt_util.utcnow()
departure_datetime = dt_util.utc_from_timestamp(departure_time)
time_difference = (departure_datetime - current_time).total_seconds()
return int(time_difference / 60.0)
class MVGLiveData: class MVGLiveData:
"""Pull data from the mvg.de web page.""" """Pull data from the mvg-live.de web page."""
def __init__( def __init__(
self, self, station, destinations, directions, lines, products, timeoffset, number
hass: HomeAssistant, ):
station_name,
destinations,
lines,
products,
timeoffset,
number,
) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
self._hass = hass self._station = station
self._station_name = station_name
self._station_id = None
self._destinations = destinations self._destinations = destinations
self._directions = directions
self._lines = lines self._lines = lines
self._products = products self._products = products
self._timeoffset = timeoffset self._timeoffset = timeoffset
self._number = number self._number = number
self.departures: list[dict[str, Any]] = [] self._include_ubahn = "U-Bahn" in self._products
self._include_tram = "Tram" in self._products
async def update(self): self._include_bus = "Bus" in self._products
"""Update the connection data.""" self._include_sbahn = "S-Bahn" in self._products
if self._station_id is None: self.mvg = MVGLive.MVGLive()
try:
station = await MvgApi.station_async(self._station_name)
self._station_id = station["id"]
except MvgApiError as err:
_LOGGER.error(
"Failed to resolve station %s: %s", self._station_name, err
)
self.departures = [] self.departures = []
return
def update(self):
"""Update the connection data."""
try: try:
_departures = await MvgApi.departures_async( _departures = self.mvg.getlivedata(
station_id=self._station_id, station=self._station,
offset=self._timeoffset, timeoffset=self._timeoffset,
limit=self._number, ubahn=self._include_ubahn,
transport_types=[ tram=self._include_tram,
transport_type bus=self._include_bus,
for transport_type in TransportType sbahn=self._include_sbahn,
if transport_type.value[0] in self._products
]
if self._products
else None,
) )
except ValueError: except ValueError:
self.departures = [] self.departures = []
_LOGGER.warning("Returned data not understood") _LOGGER.warning("Returned data not understood")
return return
self.departures = [] self.departures = []
for _departure in _departures: for i, _departure in enumerate(_departures):
# find the first departure meeting the criteria
if ( if (
"" not in self._destinations[:1] "" not in self._destinations[:1]
and _departure["destination"] not in self._destinations and _departure["destination"] not in self._destinations
): ):
continue continue
if "" not in self._lines[:1] and _departure["line"] not in self._lines: if (
"" not in self._directions[:1]
and _departure["direction"] not in self._directions
):
continue continue
time_to_departure = _get_minutes_until_departure(_departure["time"]) if "" not in self._lines[:1] and _departure["linename"] not in self._lines:
if time_to_departure < self._timeoffset:
continue continue
if _departure["time"] < self._timeoffset:
continue
# now select the relevant data
_nextdep = {} _nextdep = {}
for k in ("destination", "line", "type", "cancelled", "icon"): for k in ("destination", "linename", "time", "direction", "product"):
_nextdep[k] = _departure.get(k, "") _nextdep[k] = _departure.get(k, "")
_nextdep["time_in_mins"] = time_to_departure _nextdep["time"] = int(_nextdep["time"])
self.departures.append(_nextdep) self.departures.append(_nextdep)
if i == self._number - 1:
break

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["nibe==2.19.0"] "requirements": ["nibe==2.18.0"]
} }

View File

@@ -11,7 +11,7 @@
"_r_to_u": "City/county (R-U)", "_r_to_u": "City/county (R-U)",
"_v_to_z": "City/county (V-Z)", "_v_to_z": "City/county (V-Z)",
"slots": "Maximum warnings per city/county", "slots": "Maximum warnings per city/county",
"headline_filter": "Headline blocklist" "headline_filter": "Blacklist regex to filter warning headlines"
} }
} }
}, },
@@ -34,7 +34,7 @@
"_v_to_z": "[%key:component::nina::config::step::user::data::_v_to_z%]", "_v_to_z": "[%key:component::nina::config::step::user::data::_v_to_z%]",
"slots": "[%key:component::nina::config::step::user::data::slots%]", "slots": "[%key:component::nina::config::step::user::data::slots%]",
"headline_filter": "[%key:component::nina::config::step::user::data::headline_filter%]", "headline_filter": "[%key:component::nina::config::step::user::data::headline_filter%]",
"area_filter": "Affected area filter" "area_filter": "Whitelist regex to filter warnings based on affected areas"
} }
} }
}, },

View File

@@ -13,6 +13,6 @@ NMAP_TRACKED_DEVICES: Final = "nmap_tracked_devices"
# Interval in minutes to exclude devices from a scan while they are home # Interval in minutes to exclude devices from a scan while they are home
CONF_HOME_INTERVAL: Final = "home_interval" CONF_HOME_INTERVAL: Final = "home_interval"
CONF_OPTIONS: Final = "scan_options" CONF_OPTIONS: Final = "scan_options"
DEFAULT_OPTIONS: Final = "-n -sn -PR -T4 --min-rate 10 --host-timeout 5s" DEFAULT_OPTIONS: Final = "-F -T4 --min-rate 10 --host-timeout 5s"
TRACKER_SCAN_INTERVAL: Final = 120 TRACKER_SCAN_INTERVAL: Final = 120

View File

@@ -52,10 +52,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo
try: try:
info = await async_interview(host) info = await async_interview(host)
except TimeoutError as exc:
raise ConfigEntryNotReady(f"Timed out interviewing: {host}") from exc
except OSError as exc: except OSError as exc:
raise ConfigEntryNotReady(f"Unexpected exception interviewing: {host}") from exc raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc
if info is None:
raise ConfigEntryNotReady(f"Unable to connect to: {host}")
manager = ReceiverManager(hass, entry, info) manager = ReceiverManager(hass, entry, info)

View File

@@ -109,16 +109,18 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Config flow manual: %s", host) _LOGGER.debug("Config flow manual: %s", host)
try: try:
info = await async_interview(host) info = await async_interview(host)
except TimeoutError:
_LOGGER.warning("Timed out interviewing: %s", host)
errors["base"] = "cannot_connect"
except OSError: except OSError:
_LOGGER.exception("Unexpected exception interviewing: %s", host) _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else:
if info is None:
errors["base"] = "cannot_connect"
else: else:
self._receiver_info = info self._receiver_info = info
await self.async_set_unique_id(info.identifier, raise_on_progress=False) await self.async_set_unique_id(
info.identifier, raise_on_progress=False
)
if self.source == SOURCE_RECONFIGURE: if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch() self._abort_if_unique_id_mismatch()
else: else:
@@ -212,13 +214,14 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
try: try:
info = await async_interview(host) info = await async_interview(host)
except TimeoutError:
_LOGGER.warning("Timed out interviewing: %s", host)
return self.async_abort(reason="cannot_connect")
except OSError: except OSError:
_LOGGER.exception("Unexpected exception interviewing: %s", host) _LOGGER.exception("Unexpected exception interviewing host %s", host)
return self.async_abort(reason="unknown") return self.async_abort(reason="unknown")
if info is None:
_LOGGER.debug("SSDP eiscp is None: %s", host)
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(info.identifier) await self.async_set_unique_id(info.identifier)
self._abort_if_unique_id_configured(updates={CONF_HOST: info.host}) self._abort_if_unique_id_configured(updates={CONF_HOST: info.host})

View File

@@ -124,10 +124,13 @@ class ReceiverManager:
self.callbacks.clear() self.callbacks.clear()
async def async_interview(host: str) -> ReceiverInfo: async def async_interview(host: str) -> ReceiverInfo | None:
"""Interview the receiver.""" """Interview the receiver."""
info: ReceiverInfo | None = None
with contextlib.suppress(asyncio.TimeoutError):
async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT): async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT):
return await aioonkyo.interview(host) info = await aioonkyo.interview(host)
return info
async def async_discover(hass: HomeAssistant) -> Iterable[ReceiverInfo]: async def async_discover(hass: HomeAssistant) -> Iterable[ReceiverInfo]:

View File

@@ -5,14 +5,7 @@ from __future__ import annotations
from pyportainer import Portainer from pyportainer import Portainer
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform
CONF_API_KEY,
CONF_API_TOKEN,
CONF_HOST,
CONF_URL,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.aiohttp_client import async_create_clientsession
@@ -26,12 +19,11 @@ type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
"""Set up Portainer from a config entry.""" """Set up Portainer from a config entry."""
session = async_create_clientsession(hass)
client = Portainer( client = Portainer(
api_url=entry.data[CONF_URL], api_url=entry.data[CONF_HOST],
api_key=entry.data[CONF_API_TOKEN], api_key=entry.data[CONF_API_KEY],
session=async_create_clientsession( session=session,
hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL]
),
) )
coordinator = PortainerCoordinator(hass, entry, client) coordinator = PortainerCoordinator(hass, entry, client)
@@ -46,15 +38,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version < 2:
data = dict(entry.data)
data[CONF_URL] = data.pop(CONF_HOST)
data[CONF_API_TOKEN] = data.pop(CONF_API_KEY)
hass.config_entries.async_update_entry(entry=entry, data=data, version=2)
return True

View File

@@ -131,15 +131,7 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
self.entity_description = entity_description self.entity_description = entity_description
super().__init__(device_info, coordinator, via_device) super().__init__(device_info, coordinator, via_device)
# Container ID's are ephemeral, so use the container name for the unique ID self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}"
# The first one, should always be unique, it's fine if users have aliases
# According to Docker's API docs, the first name is unique
device_identifier = (
self._device_info.names[0].replace("/", " ").strip()
if self._device_info.names
else None
)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}"
@property @property
def available(self) -> bool: def available(self) -> bool:

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
@@ -15,7 +14,7 @@ from pyportainer import (
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -25,9 +24,8 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema( STEP_USER_DATA_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_URL): str, vol.Required(CONF_HOST): str,
vol.Required(CONF_API_TOKEN): str, vol.Required(CONF_API_KEY): str,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
} }
) )
@@ -36,11 +34,9 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.""" """Validate the user input allows us to connect."""
client = Portainer( client = Portainer(
api_url=data[CONF_URL], api_url=data[CONF_HOST],
api_key=data[CONF_API_TOKEN], api_key=data[CONF_API_KEY],
session=async_get_clientsession( session=async_get_clientsession(hass),
hass=hass, verify_ssl=data.get(CONF_VERIFY_SSL, True)
),
) )
try: try:
await client.get_endpoints() await client.get_endpoints()
@@ -51,21 +47,19 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
except PortainerTimeoutError as err: except PortainerTimeoutError as err:
raise PortainerTimeout from err raise PortainerTimeout from err
_LOGGER.debug("Connected to Portainer API: %s", data[CONF_URL]) _LOGGER.debug("Connected to Portainer API: %s", data[CONF_HOST])
class PortainerConfigFlow(ConfigFlow, domain=DOMAIN): class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Portainer.""" """Handle a config flow for Portainer."""
VERSION = 2
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
try: try:
await _validate_input(self.hass, user_input) await _validate_input(self.hass, user_input)
except CannotConnect: except CannotConnect:
@@ -78,58 +72,16 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
await self.async_set_unique_id(user_input[CONF_API_TOKEN]) await self.async_set_unique_id(user_input[CONF_API_KEY])
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return self.async_create_entry( return self.async_create_entry(
title=user_input[CONF_URL], data=user_input title=user_input[CONF_HOST], data=user_input
) )
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
) )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth when Portainer API authentication fails."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth: ask for new API token and validate."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
try:
await _validate_input(
self.hass,
data={
**reauth_entry.data,
CONF_API_TOKEN: user_input[CONF_API_TOKEN],
},
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except PortainerTimeout:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}),
errors=errors,
)
class CannotConnect(HomeAssistantError): class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect.""" """Error to indicate we cannot connect."""

View File

@@ -16,9 +16,9 @@ from pyportainer.models.docker import DockerContainer
from pyportainer.models.portainer import Endpoint from pyportainer.models.portainer import Endpoint
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
@@ -66,7 +66,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
try: try:
await self.portainer.get_endpoints() await self.portainer.get_endpoints()
except PortainerAuthenticationError as err: except PortainerAuthenticationError as err:
raise ConfigEntryAuthFailed( raise ConfigEntryError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_auth", translation_key="invalid_auth",
translation_placeholders={"error": repr(err)}, translation_placeholders={"error": repr(err)},
@@ -87,14 +87,14 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]:
"""Fetch data from Portainer API.""" """Fetch data from Portainer API."""
_LOGGER.debug( _LOGGER.debug(
"Fetching data from Portainer API: %s", self.config_entry.data[CONF_URL] "Fetching data from Portainer API: %s", self.config_entry.data[CONF_HOST]
) )
try: try:
endpoints = await self.portainer.get_endpoints() endpoints = await self.portainer.get_endpoints()
except PortainerAuthenticationError as err: except PortainerAuthenticationError as err:
_LOGGER.error("Authentication error: %s", repr(err)) _LOGGER.error("Authentication error: %s", repr(err))
raise ConfigEntryAuthFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_auth", translation_key="invalid_auth",
translation_placeholders={"error": repr(err)}, translation_placeholders={"error": repr(err)},
@@ -121,7 +121,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
) from err ) from err
except PortainerAuthenticationError as err: except PortainerAuthenticationError as err:
_LOGGER.exception("Authentication error") _LOGGER.exception("Authentication error")
raise ConfigEntryAuthFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_auth", translation_key="invalid_auth",
translation_placeholders={"error": repr(err)}, translation_placeholders={"error": repr(err)},

View File

@@ -60,7 +60,7 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={ identifiers={
(DOMAIN, f"{self.coordinator.config_entry.entry_id}_{device_name}") (DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.device_id}")
}, },
manufacturer=DEFAULT_NAME, manufacturer=DEFAULT_NAME,
model="Container", model="Container",

View File

@@ -3,25 +3,14 @@
"step": { "step": {
"user": { "user": {
"data": { "data": {
"url": "[%key:common::config_flow::data::url%]", "host": "[%key:common::config_flow::data::host%]",
"api_token": "[%key:common::config_flow::data::api_token%]", "api_key": "[%key:common::config_flow::data::api_key%]"
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}, },
"data_description": { "data_description": {
"url": "The URL, including the port, of your Portainer instance", "host": "The host/URL, including the port, of your Portainer instance",
"api_token": "The API access token for authenticating with Portainer", "api_key": "The API key for authenticating with Portainer"
"verify_ssl": "Whether to verify SSL certificates. Disable only if you have a self-signed certificate"
}, },
"description": "You can create an access token in the Portainer UI. Go to **My account > Access tokens** and select **Add access token**" "description": "You can create an API key in the Portainer UI. Go to **My account > API keys** and select **Add API key**"
},
"reauth_confirm": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
},
"data_description": {
"api_token": "The new API access token for authenticating with Portainer"
},
"description": "The access token for your Portainer instance needs to be re-authenticated. You can create a new access token in the Portainer UI. Go to **My account > Access tokens** and select **Add access token**"
} }
}, },
"error": { "error": {
@@ -31,8 +20,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
}, },
"device": { "device": {

View File

@@ -9,9 +9,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups( await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["entity_type"],) entry, (entry.options["entity_type"],)
) )
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms( return await hass.config_entries.async_unload_platforms(

View File

@@ -184,7 +184,6 @@ class RandomConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW
options_flow_reloads = True
@callback @callback
def async_config_entry_title(self, options: Mapping[str, Any]) -> str: def async_config_entry_title(self, options: Mapping[str, Any]) -> str:

View File

@@ -351,9 +351,13 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
def _set_current_map(self) -> None: def _set_current_map(self) -> None:
if ( if (
self.roborock_device_info.props.status is not None self.roborock_device_info.props.status is not None
and self.roborock_device_info.props.status.current_map is not None and self.roborock_device_info.props.status.map_status is not None
): ):
self.current_map = self.roborock_device_info.props.status.current_map # The map status represents the map flag as flag * 4 + 3 -
# so we have to invert that in order to get the map flag that we can use to set the current map.
self.current_map = (
self.roborock_device_info.props.status.map_status - 3
) // 4
async def set_current_map_rooms(self) -> None: async def set_current_map_rooms(self) -> None:
"""Fetch all of the rooms for the current map and set on RoborockMapInfo.""" """Fetch all of the rooms for the current map and set on RoborockMapInfo."""
@@ -436,7 +440,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
# If either of these fail, we don't care, and we want to continue. # If either of these fail, we don't care, and we want to continue.
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
if len(self.maps) > 1: if len(self.maps) != 1:
# Set the map back to the map the user previously had selected so that it # Set the map back to the map the user previously had selected so that it
# does not change the end user's app. # does not change the end user's app.
# Only needs to happen when we changed maps above. # Only needs to happen when we changed maps above.

View File

@@ -7,6 +7,6 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiorussound"], "loggers": ["aiorussound"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["aiorussound==4.8.2"], "requirements": ["aiorussound==4.8.1"],
"zeroconf": ["_rio._tcp.local."] "zeroconf": ["_rio._tcp.local."]
} }

View File

@@ -6,14 +6,9 @@ from datetime import date, datetime
import logging import logging
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import DOMAIN, SensorDeviceClass, SensorStateClass from . import SensorDeviceClass
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -42,31 +37,3 @@ def async_parse_date_datetime(
_LOGGER.warning("%s rendered invalid date %s", entity_id, value) _LOGGER.warning("%s rendered invalid date %s", entity_id, value)
return None return None
@callback
def create_sensor_device_class_select_selector() -> SelectSelector:
"""Create sensor device class select selector."""
return SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorDeviceClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class",
translation_domain=DOMAIN,
sort=True,
)
)
@callback
def create_sensor_state_class_select_selector() -> SelectSelector:
"""Create sensor state class select selector."""
return SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorStateClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="state_class",
translation_domain=DOMAIN,
sort=True,
)
)

View File

@@ -334,76 +334,5 @@
"title": "The unit of {statistic_id} has changed", "title": "The unit of {statistic_id} has changed",
"description": "" "description": ""
} }
},
"selector": {
"device_class": {
"options": {
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
"current": "[%key:component::sensor::entity_component::current::name%]",
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
"date": "[%key:component::sensor::entity_component::date::name%]",
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"enum": "Enumeration",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
"water": "[%key:component::sensor::entity_component::water::name%]",
"weight": "[%key:component::sensor::entity_component::weight::name%]",
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
},
"state_class": {
"options": {
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
"measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]",
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
}
}
} }
} }

View File

@@ -30,5 +30,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pysmartthings"], "loggers": ["pysmartthings"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pysmartthings==3.3.0"] "requirements": ["pysmartthings==3.2.9"]
} }

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