diff --git a/.coveragerc b/.coveragerc index 752ea5ca7bc571..2f899999f4130e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -29,7 +29,8 @@ omit = homeassistant/components/adguard/switch.py homeassistant/components/ads/* homeassistant/components/aemet/weather_update_coordinator.py - homeassistant/components/aftership/* + homeassistant/components/aftership/__init__.py + homeassistant/components/aftership/sensor.py homeassistant/components/agent_dvr/alarm_control_panel.py homeassistant/components/agent_dvr/camera.py homeassistant/components/agent_dvr/helpers.py @@ -174,6 +175,7 @@ omit = homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comelit/__init__.py homeassistant/components/comelit/const.py + homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py homeassistant/components/comelit/light.py homeassistant/components/comfoconnect/fan.py @@ -246,6 +248,8 @@ omit = homeassistant/components/duotecno/switch.py homeassistant/components/duotecno/cover.py homeassistant/components/duotecno/light.py + homeassistant/components/duotecno/climate.py + homeassistant/components/duotecno/binary_sensor.py homeassistant/components/dwd_weather_warnings/const.py homeassistant/components/dwd_weather_warnings/coordinator.py homeassistant/components/dwd_weather_warnings/sensor.py @@ -723,6 +727,8 @@ omit = homeassistant/components/matter/__init__.py homeassistant/components/meater/__init__.py homeassistant/components/meater/sensor.py + homeassistant/components/medcom_ble/__init__.py + homeassistant/components/medcom_ble/sensor.py homeassistant/components/mediaroom/media_player.py homeassistant/components/melcloud/__init__.py homeassistant/components/melcloud/climate.py @@ -762,6 +768,7 @@ omit = homeassistant/components/moehlenhoff_alpha2/climate.py homeassistant/components/moehlenhoff_alpha2/sensor.py homeassistant/components/motion_blinds/__init__.py + homeassistant/components/motion_blinds/coordinator.py homeassistant/components/motion_blinds/cover.py homeassistant/components/motion_blinds/entity.py homeassistant/components/motion_blinds/sensor.py @@ -972,6 +979,8 @@ omit = homeassistant/components/point/sensor.py homeassistant/components/poolsense/__init__.py homeassistant/components/poolsense/binary_sensor.py + homeassistant/components/poolsense/coordinator.py + homeassistant/components/poolsense/entity.py homeassistant/components/poolsense/sensor.py homeassistant/components/powerwall/__init__.py homeassistant/components/progettihwsw/__init__.py @@ -1473,6 +1482,7 @@ omit = homeassistant/components/vlc_telnet/__init__.py homeassistant/components/vlc_telnet/media_player.py homeassistant/components/vodafone_station/__init__.py + homeassistant/components/vodafone_station/button.py homeassistant/components/vodafone_station/const.py homeassistant/components/vodafone_station/coordinator.py homeassistant/components/vodafone_station/device_tracker.py @@ -1497,6 +1507,9 @@ omit = homeassistant/components/watson_tts/tts.py homeassistant/components/watttime/__init__.py homeassistant/components/watttime/sensor.py + homeassistant/components/weatherflow/__init__.py + homeassistant/components/weatherflow/const.py + homeassistant/components/weatherflow/sensor.py homeassistant/components/wiffi/__init__.py homeassistant/components/wiffi/binary_sensor.py homeassistant/components/wiffi/sensor.py diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 191b510c0ffdec..20d158ed676b66 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 with: fetch-depth: 0 @@ -56,7 +56,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 @@ -98,7 +98,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -252,7 +252,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set build additional args run: | @@ -289,7 +289,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -327,7 +327,7 @@ jobs: id-token: write steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Install Cosign uses: sigstore/cosign-installer@v3.1.2 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2ac6773b6e906b..1e81bed2965661 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,9 +35,9 @@ on: env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 - MYPY_CACHE_VERSION: 4 + MYPY_CACHE_VERSION: 5 BLACK_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2023.10" + HA_SHORT_VERSION: "2023.11" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11']" # 10.3 is the oldest supported version @@ -89,7 +89,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -222,7 +222,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -267,7 +267,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -335,7 +335,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -384,7 +384,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -478,7 +478,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -546,7 +546,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -578,7 +578,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -611,7 +611,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -655,7 +655,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -737,7 +737,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -889,7 +889,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -1013,7 +1013,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -1108,7 +1108,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index a98c4d99734fce..84d7fc03e437c4 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 0bf89a8e050c1d..dd153299bce0c9 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -10,8 +10,10 @@ on: - dev - rc paths: - - "requirements.txt" + - ".github/workflows/wheels.yml" + - "homeassistant/package_constraints.txt" - "requirements_all.txt" + - "requirements.txt" concurrency: group: ${{ github.workflow }}-${{ github.ref_name}} @@ -26,7 +28,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Get information id: info @@ -84,7 +86,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -122,7 +124,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -186,7 +188,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -200,7 +202,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -214,7 +216,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" diff --git a/.strict-typing b/.strict-typing index 97af46884c4f8d..6b2c52f42f6a61 100644 --- a/.strict-typing +++ b/.strict-typing @@ -214,6 +214,7 @@ homeassistant.components.local_ip.* homeassistant.components.lock.* homeassistant.components.logbook.* homeassistant.components.logger.* +homeassistant.components.london_underground.* homeassistant.components.lookin.* homeassistant.components.luftdaten.* homeassistant.components.mailbox.* @@ -260,6 +261,7 @@ homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.ping.* homeassistant.components.plugwise.* +homeassistant.components.poolsense.* homeassistant.components.powerwall.* homeassistant.components.private_ble_device.* homeassistant.components.proximity.* diff --git a/CODEOWNERS b/CODEOWNERS index e728d70c1bc3a5..661d21fd95c2f5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -233,8 +233,8 @@ build.json @home-assistant/supervisor /tests/components/counter/ @fabaff /homeassistant/components/cover/ @home-assistant/core /tests/components/cover/ @home-assistant/core -/homeassistant/components/cpuspeed/ @fabaff @frenck -/tests/components/cpuspeed/ @fabaff @frenck +/homeassistant/components/cpuspeed/ @fabaff +/tests/components/cpuspeed/ @fabaff /homeassistant/components/crownstone/ @Crownstone @RicArch97 /tests/components/crownstone/ @Crownstone @RicArch97 /homeassistant/components/cups/ @fabaff @@ -742,6 +742,8 @@ build.json @home-assistant/supervisor /tests/components/mazda/ @bdr99 /homeassistant/components/meater/ @Sotolotl @emontnemery /tests/components/meater/ @Sotolotl @emontnemery +/homeassistant/components/medcom_ble/ @elafargue +/tests/components/medcom_ble/ @elafargue /homeassistant/components/media_extractor/ @joostlek /tests/components/media_extractor/ @joostlek /homeassistant/components/media_player/ @home-assistant/core @@ -807,8 +809,8 @@ build.json @home-assistant/supervisor /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core /tests/components/my/ @home-assistant/core -/homeassistant/components/myq/ @ehendrix23 -/tests/components/myq/ @ehendrix23 +/homeassistant/components/myq/ @ehendrix23 @Lash-L +/tests/components/myq/ @ehendrix23 @Lash-L /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff @@ -1415,6 +1417,8 @@ build.json @home-assistant/supervisor /tests/components/waze_travel_time/ @eifinger /homeassistant/components/weather/ @home-assistant/core /tests/components/weather/ @home-assistant/core +/homeassistant/components/weatherflow/ @natekspencer @jeeftor +/tests/components/weatherflow/ @natekspencer @jeeftor /homeassistant/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner /homeassistant/components/webhook/ @home-assistant/core diff --git a/Dockerfile b/Dockerfile index e229f27cb33595..f2a365b2b8af47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,8 @@ COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ RUN \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ @@ -39,9 +38,8 @@ RUN \ MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core @@ -49,9 +47,8 @@ COPY . homeassistant/ RUN \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -e ./homeassistant \ && python3 -m compileall \ homeassistant/homeassistant diff --git a/build.yaml b/build.yaml index cc13a4e595f94f..f9e19f89e23c53 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.08.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.08.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.08.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.08.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.08.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.09.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.09.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.09.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.09.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.09.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/components/aftership/__init__.py b/homeassistant/components/aftership/__init__.py index b063c919f18f83..66610e6e01be72 100644 --- a/homeassistant/components/aftership/__init__.py +++ b/homeassistant/components/aftership/__init__.py @@ -1 +1,42 @@ -"""The aftership component.""" +"""The AfterShip integration.""" +from __future__ import annotations + +from pyaftership import AfterShip, AfterShipException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up AfterShip from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + session = async_get_clientsession(hass) + aftership = AfterShip(api_key=entry.data[CONF_API_KEY], session=session) + + try: + await aftership.trackings.list() + except AfterShipException as err: + raise ConfigEntryNotReady from err + + hass.data[DOMAIN][entry.entry_id] = aftership + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/aftership/config_flow.py b/homeassistant/components/aftership/config_flow.py new file mode 100644 index 00000000000000..3da6ac9e3d5a92 --- /dev/null +++ b/homeassistant/components/aftership/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for AfterShip integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyaftership import AfterShip, AfterShipException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AfterShipConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for AfterShip.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]}) + try: + aftership = AfterShip( + api_key=user_input[CONF_API_KEY], + session=async_get_clientsession(self.hass), + ) + await aftership.trackings.list() + except AfterShipException: + _LOGGER.exception("Aftership raised exception") + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title="AfterShip", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import configuration from yaml.""" + try: + self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]}) + except AbortFlow as err: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_already_configured", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_already_configured", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "AfterShip", + }, + ) + raise err + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "AfterShip", + }, + ) + return self.async_create_entry( + title=config.get(CONF_NAME, "AfterShip"), + data={CONF_API_KEY: config[CONF_API_KEY]}, + ) diff --git a/homeassistant/components/aftership/manifest.json b/homeassistant/components/aftership/manifest.json index 1cfc88a6f9dd45..eb4fffa57bc246 100644 --- a/homeassistant/components/aftership/manifest.json +++ b/homeassistant/components/aftership/manifest.json @@ -2,6 +2,7 @@ "domain": "aftership", "name": "AfterShip", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aftership", "iot_class": "cloud_polling", "requirements": ["pyaftership==21.11.0"] diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index d816afa3b17dbf..a3b85f2188d50f 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -11,6 +11,7 @@ PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -20,6 +21,7 @@ async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle @@ -58,19 +60,43 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the AfterShip sensor platform.""" - apikey = config[CONF_API_KEY] - name = config[CONF_NAME] - - session = async_get_clientsession(hass) - aftership = AfterShip(api_key=apikey, session=session) - + aftership = AfterShip( + api_key=config[CONF_API_KEY], session=async_get_clientsession(hass) + ) try: await aftership.trackings.list() - except AfterShipException as err: - _LOGGER.error("No tracking data found. Check API key is correct: %s", err) - return + except AfterShipException: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_cannot_connect", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_cannot_connect", + translation_placeholders={ + "integration_title": "AfterShip", + "url": "/config/integrations/dashboard/add?domain=aftership", + }, + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AfterShip sensor entities based on a config entry.""" + aftership: AfterShip = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([AfterShipSensor(aftership, name)], True) + async_add_entities([AfterShipSensor(aftership, config_entry.title)], True) async def handle_add_tracking(call: ServiceCall) -> None: """Call when a user adds a new Aftership tracking from Home Assistant.""" diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json index a7ccdd48202176..b49c19976a6354 100644 --- a/homeassistant/components/aftership/strings.json +++ b/homeassistant/components/aftership/strings.json @@ -1,4 +1,19 @@ { + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, "services": { "add_tracking": { "name": "Add tracking", @@ -32,5 +47,15 @@ } } } + }, + "issues": { + "deprecated_yaml_import_issue_already_configured": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but the YAML configuration was already imported.\n\nRemove the YAML configuration and restart Home Assistant." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } } } diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 8860db69b7916a..e07400f27640e8 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -421,9 +421,9 @@ def __init__( self._entry = entry self.entity_description = description - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" + await super().async_added_to_hass() @callback def update() -> None: diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index 732f159c38178f..38c764d4889908 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -14,6 +14,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.CLIMATE, Platform.SENSOR, ] diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py new file mode 100644 index 00000000000000..a86440bad20d2c --- /dev/null +++ b/homeassistant/components/airzone_cloud/climate.py @@ -0,0 +1,343 @@ +"""Support for the Airzone Cloud climate.""" +from __future__ import annotations + +from typing import Any, Final + +from aioairzone_cloud.common import OperationAction, OperationMode, TemperatureUnit +from aioairzone_cloud.const import ( + API_MODE, + API_OPTS, + API_PARAMS, + API_POWER, + API_SETPOINT, + API_UNITS, + API_VALUE, + AZD_ACTION, + AZD_AIDOOS, + AZD_GROUPS, + AZD_HUMIDITY, + AZD_MASTER, + AZD_MODE, + AZD_MODES, + AZD_NUM_DEVICES, + AZD_POWER, + AZD_TEMP, + AZD_TEMP_SET, + AZD_TEMP_SET_MAX, + AZD_TEMP_SET_MIN, + AZD_TEMP_STEP, + AZD_ZONES, +) + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import ( + AirzoneAidooEntity, + AirzoneEntity, + AirzoneGroupEntity, + AirzoneZoneEntity, +) + +HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationAction, HVACAction]] = { + OperationAction.COOLING: HVACAction.COOLING, + OperationAction.DRYING: HVACAction.DRYING, + OperationAction.FAN: HVACAction.FAN, + OperationAction.HEATING: HVACAction.HEATING, + OperationAction.IDLE: HVACAction.IDLE, + OperationAction.OFF: HVACAction.OFF, +} +HVAC_MODE_LIB_TO_HASS: Final[dict[OperationMode, HVACMode]] = { + OperationMode.STOP: HVACMode.OFF, + OperationMode.COOLING: HVACMode.COOL, + OperationMode.COOLING_AIR: HVACMode.COOL, + OperationMode.COOLING_RADIANT: HVACMode.COOL, + OperationMode.COOLING_COMBINED: HVACMode.COOL, + OperationMode.HEATING: HVACMode.HEAT, + OperationMode.HEAT_AIR: HVACMode.HEAT, + OperationMode.HEAT_RADIANT: HVACMode.HEAT, + OperationMode.HEAT_COMBINED: HVACMode.HEAT, + OperationMode.EMERGENCY_HEAT: HVACMode.HEAT, + OperationMode.VENTILATION: HVACMode.FAN_ONLY, + OperationMode.DRY: HVACMode.DRY, + OperationMode.AUTO: HVACMode.HEAT_COOL, +} +HVAC_MODE_HASS_TO_LIB: Final[dict[HVACMode, OperationMode]] = { + HVACMode.OFF: OperationMode.STOP, + HVACMode.COOL: OperationMode.COOLING, + HVACMode.HEAT: OperationMode.HEATING, + HVACMode.FAN_ONLY: OperationMode.VENTILATION, + HVACMode.DRY: OperationMode.DRY, + HVACMode.HEAT_COOL: OperationMode.AUTO, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone climate from a config_entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[AirzoneClimate] = [] + + # Aidoos + for aidoo_id, aidoo_data in coordinator.data.get(AZD_AIDOOS, {}).items(): + entities.append( + AirzoneAidooClimate( + coordinator, + aidoo_id, + aidoo_data, + ) + ) + + # Groups + for group_id, group_data in coordinator.data.get(AZD_GROUPS, {}).items(): + if group_data[AZD_NUM_DEVICES] > 1: + entities.append( + AirzoneGroupClimate( + coordinator, + group_id, + group_data, + ) + ) + + # Zones + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): + entities.append( + AirzoneZoneClimate( + coordinator, + zone_id, + zone_data, + ) + ) + + async_add_entities(entities) + + +class AirzoneClimate(AirzoneEntity, ClimateEntity): + """Define an Airzone Cloud climate.""" + + _attr_has_entity_name = True + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + params = { + API_POWER: { + API_VALUE: True, + }, + } + await self._async_update_params(params) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + params = { + API_POWER: { + API_VALUE: False, + }, + } + await self._async_update_params(params) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + params: dict[str, Any] = {} + if ATTR_TEMPERATURE in kwargs: + params[API_SETPOINT] = { + API_VALUE: kwargs[ATTR_TEMPERATURE], + API_OPTS: { + API_UNITS: TemperatureUnit.CELSIUS.value, + }, + } + await self._async_update_params(params) + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update climate attributes.""" + self._attr_current_temperature = self.get_airzone_value(AZD_TEMP) + self._attr_current_humidity = self.get_airzone_value(AZD_HUMIDITY) + self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[ + self.get_airzone_value(AZD_ACTION) + ] + if self.get_airzone_value(AZD_POWER): + self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ + self.get_airzone_value(AZD_MODE) + ] + else: + self._attr_hvac_mode = HVACMode.OFF + self._attr_max_temp = self.get_airzone_value(AZD_TEMP_SET_MAX) + self._attr_min_temp = self.get_airzone_value(AZD_TEMP_SET_MIN) + self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) + + +class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneClimate): + """Define an Airzone Cloud Aidoo climate.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + aidoo_id: str, + aidoo_data: dict, + ) -> None: + """Initialize Airzone Cloud Aidoo climate.""" + super().__init__(coordinator, aidoo_id, aidoo_data) + + self._attr_unique_id = aidoo_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + params: dict[str, Any] = {} + if hvac_mode == HVACMode.OFF: + params[API_POWER] = { + API_VALUE: False, + } + else: + mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] + params[API_MODE] = { + API_VALUE: mode.value, + } + params[API_POWER] = { + API_VALUE: True, + } + await self._async_update_params(params) + + +class AirzoneGroupClimate(AirzoneGroupEntity, AirzoneClimate): + """Define an Airzone Cloud Group climate.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + group_id: str, + group_data: dict, + ) -> None: + """Initialize Airzone Cloud Group climate.""" + super().__init__(coordinator, group_id, group_data) + + self._attr_unique_id = group_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + params = { + API_PARAMS: { + API_POWER: True, + }, + } + await self._async_update_params(params) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + params = { + API_PARAMS: { + API_POWER: False, + }, + } + await self._async_update_params(params) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + params: dict[str, Any] = {} + if ATTR_TEMPERATURE in kwargs: + params[API_PARAMS] = { + API_SETPOINT: kwargs[ATTR_TEMPERATURE], + } + params[API_OPTS] = { + API_UNITS: TemperatureUnit.CELSIUS.value, + } + await self._async_update_params(params) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + params: dict[str, Any] = { + API_PARAMS: {}, + } + if hvac_mode == HVACMode.OFF: + params[API_PARAMS][API_POWER] = False + else: + mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] + params[API_PARAMS][API_MODE] = mode.value + params[API_PARAMS][API_POWER] = True + await self._async_update_params(params) + + +class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneClimate): + """Define an Airzone Cloud Zone climate.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + system_zone_id: str, + zone_data: dict, + ) -> None: + """Initialize Airzone Cloud Zone climate.""" + super().__init__(coordinator, system_zone_id, zone_data) + + self._attr_unique_id = system_zone_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + slave_raise = False + + params: dict[str, Any] = {} + if hvac_mode == HVACMode.OFF: + params[API_POWER] = { + API_VALUE: False, + } + else: + mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] + if mode != self.get_airzone_value(AZD_MODE): + if self.get_airzone_value(AZD_MASTER): + params[API_MODE] = { + API_VALUE: mode.value, + } + else: + slave_raise = True + params[API_POWER] = { + API_VALUE: True, + } + + await self._async_update_params(params) + + if slave_raise: + raise HomeAssistantError(f"Mode can't be changed on slave zone {self.name}") diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 090e81e417081f..749d4615e65ed8 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -2,12 +2,14 @@ from __future__ import annotations from abc import ABC, abstractmethod +import logging from typing import Any from aioairzone_cloud.const import ( AZD_AIDOOS, AZD_AVAILABLE, AZD_FIRMWARE, + AZD_GROUPS, AZD_NAME, AZD_SYSTEM_ID, AZD_SYSTEMS, @@ -15,7 +17,9 @@ AZD_WEBSERVERS, AZD_ZONES, ) +from aioairzone_cloud.exceptions import AirzoneCloudError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -23,6 +27,8 @@ from .const import DOMAIN, MANUFACTURER from .coordinator import AirzoneUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): """Define an Airzone Cloud entity.""" @@ -36,6 +42,10 @@ def available(self) -> bool: def get_airzone_value(self, key: str) -> Any: """Return Airzone Cloud entity value by key.""" + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Airzone parameters to Cloud API.""" + raise NotImplementedError + class AirzoneAidooEntity(AirzoneEntity): """Define an Airzone Cloud Aidoo entity.""" @@ -65,6 +75,62 @@ def get_airzone_value(self, key: str) -> Any: value = aidoo.get(key) return value + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Aidoo parameters to Cloud API.""" + _LOGGER.debug("aidoo=%s: update_params=%s", self.name, params) + try: + await self.coordinator.airzone.api_set_aidoo_id_params( + self.aidoo_id, params + ) + except AirzoneCloudError as error: + raise HomeAssistantError( + f"Failed to set {self.name} params: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + + +class AirzoneGroupEntity(AirzoneEntity): + """Define an Airzone Cloud Group entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + group_id: str, + group_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.group_id = group_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, group_id)}, + manufacturer=MANUFACTURER, + name=group_data[AZD_NAME], + ) + + def get_airzone_value(self, key: str) -> Any: + """Return Group value by key.""" + value = None + if group := self.coordinator.data[AZD_GROUPS].get(self.group_id): + value = group.get(key) + return value + + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Group parameters to Cloud API.""" + _LOGGER.debug("group=%s: update_params=%s", self.name, params) + try: + await self.coordinator.airzone.api_set_group_id_params( + self.group_id, params + ) + except AirzoneCloudError as error: + raise HomeAssistantError( + f"Failed to set {self.name} params: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + class AirzoneSystemEntity(AirzoneEntity): """Define an Airzone Cloud System entity.""" @@ -153,3 +219,15 @@ def get_airzone_value(self, key: str) -> Any: if zone := self.coordinator.data[AZD_ZONES].get(self.zone_id): value = zone.get(key) return value + + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Zone parameters to Cloud API.""" + _LOGGER.debug("zone=%s: update_params=%s", self.name, params) + try: + await self.coordinator.airzone.api_set_zone_id_params(self.zone_id, params) + except AirzoneCloudError as error: + raise HomeAssistantError( + f"Failed to set {self.name} params: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 289565f0473803..418b6538a42733 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.2.1"] + "requirements": ["aioairzone-cloud==0.2.4"] } diff --git a/homeassistant/components/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py index 23694654eb3a6a..e75c67cb2c521a 100644 --- a/homeassistant/components/anthemav/config_flow.py +++ b/homeassistant/components/anthemav/config_flow.py @@ -9,8 +9,8 @@ from anthemav.device_error import DeviceError import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac @@ -43,7 +43,7 @@ async def connect_device(user_input: dict[str, Any]) -> Connection: return avr -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class AnthemAVConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthem A/V Receivers.""" VERSION = 1 @@ -57,9 +57,6 @@ async def async_step_user( step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - if CONF_NAME not in user_input: - user_input[CONF_NAME] = DEFAULT_NAME - errors = {} avr: Connection | None = None @@ -84,7 +81,7 @@ async def async_step_user( user_input[CONF_MODEL] = avr.protocol.model await self.async_set_unique_id(user_input[CONF_MAC]) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) finally: if avr is not None: avr.close() diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 4056a34995a945..91f8536d348f46 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -13,7 +13,7 @@ MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_NAME +from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -30,7 +30,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - name = config_entry.data[CONF_NAME] + name = config_entry.title mac_address = config_entry.data[CONF_MAC] model = config_entry.data[CONF_MODEL] diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index d7e5e33070559a..14eedd279b8df7 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -60,7 +60,6 @@ def __init__( self._attr_icon = { "clf": "mdi:flask", - "ph": "mdi:ph", "rx": "mdi:test-tube", "waterLevel": "mdi:waves", "waterTemp": "mdi:coolant-temperature", @@ -69,6 +68,7 @@ def __init__( self._attr_device_class = { "airTemp": SensorDeviceClass.TEMPERATURE, "waterTemp": SensorDeviceClass.TEMPERATURE, + "ph": SensorDeviceClass.PH, }.get(self._variable.type) @property diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 7f87bd254d01d8..9a61346f673c16 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -12,6 +12,7 @@ from .const import DATA_CONFIG, DOMAIN from .error import PipelineNotFound from .pipeline import ( + AudioSettings, Pipeline, PipelineEvent, PipelineEventCallback, @@ -33,6 +34,7 @@ "async_get_pipelines", "async_setup", "async_pipeline_from_audio_stream", + "AudioSettings", "Pipeline", "PipelineEvent", "PipelineEventType", @@ -71,6 +73,7 @@ async def async_pipeline_from_audio_stream( conversation_id: str | None = None, tts_audio_output: str | None = None, wake_word_settings: WakeWordSettings | None = None, + audio_settings: AudioSettings | None = None, device_id: str | None = None, start_stage: PipelineStage = PipelineStage.STT, end_stage: PipelineStage = PipelineStage.TTS, @@ -93,6 +96,7 @@ async def async_pipeline_from_audio_stream( event_callback=event_callback, tts_audio_output=tts_audio_output, wake_word_settings=wake_word_settings, + audio_settings=audio_settings or AudioSettings(), ), ) await pipeline_input.validate() diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py index 094913424b63c7..209e2611ec0f42 100644 --- a/homeassistant/components/assist_pipeline/error.py +++ b/homeassistant/components/assist_pipeline/error.py @@ -22,6 +22,14 @@ class WakeWordDetectionError(PipelineError): """Error in wake-word-detection portion of pipeline.""" +class WakeWordDetectionAborted(WakeWordDetectionError): + """Wake-word-detection was aborted.""" + + def __init__(self) -> None: + """Set error message.""" + super().__init__("wake_word_detection_aborted", "") + + class WakeWordTimeoutError(WakeWordDetectionError): """Timeout when wake word was not detected.""" diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index 1db415b29d2f21..31b3b0d4e32abf 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["webrtcvad==2.0.10"] + "requirements": ["webrtc-noise-gain==1.2.3"] } diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index f4d060ed7b82d5..7e4c71671adbdc 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1,7 +1,9 @@ """Classes for voice assistant pipelines.""" from __future__ import annotations +import array import asyncio +from collections import deque from collections.abc import AsyncGenerator, AsyncIterable, Callable, Iterable from dataclasses import asdict, dataclass, field from enum import StrEnum @@ -10,10 +12,11 @@ from queue import Queue from threading import Thread import time -from typing import Any, cast +from typing import Any, Final, cast import wave import voluptuous as vol +from webrtc_noise_gain import AudioProcessor from homeassistant.components import ( conversation, @@ -29,6 +32,7 @@ from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.collection import ( + CHANGE_UPDATED, CollectionError, ItemNotFound, SerializedStorageCollection, @@ -51,16 +55,17 @@ PipelineNotFound, SpeechToTextError, TextToSpeechError, + WakeWordDetectionAborted, WakeWordDetectionError, WakeWordTimeoutError, ) -from .ring_buffer import RingBuffer -from .vad import VoiceActivityTimeout, VoiceCommandSegmenter +from .vad import AudioBuffer, VoiceActivityTimeout, VoiceCommandSegmenter, chunk_samples _LOGGER = logging.getLogger(__name__) STORAGE_KEY = f"{DOMAIN}.pipelines" STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 ENGINE_LANGUAGE_PAIRS = ( ("stt_engine", "stt_language"), @@ -86,12 +91,17 @@ def validate_language(data: dict[str, Any]) -> Any: vol.Required("tts_engine"): vol.Any(str, None), vol.Required("tts_language"): vol.Any(str, None), vol.Required("tts_voice"): vol.Any(str, None), + vol.Required("wake_word_entity"): vol.Any(str, None), + vol.Required("wake_word_id"): vol.Any(str, None), } STORED_PIPELINE_RUNS = 10 SAVE_DELAY = 10 +AUDIO_PROCESSOR_SAMPLES: Final = 160 # 10 ms @ 16 Khz +AUDIO_PROCESSOR_BYTES: Final = AUDIO_PROCESSOR_SAMPLES * 2 # 16-bit samples + async def _async_resolve_default_pipeline_settings( hass: HomeAssistant, @@ -111,6 +121,8 @@ async def _async_resolve_default_pipeline_settings( tts_engine = None tts_language = None tts_voice = None + wake_word_entity = None + wake_word_id = None # Find a matching language supported by the Home Assistant conversation agent conversation_languages = language_util.matches( @@ -188,6 +200,8 @@ async def _async_resolve_default_pipeline_settings( "tts_engine": tts_engine_id, "tts_language": tts_language, "tts_voice": tts_voice, + "wake_word_entity": wake_word_entity, + "wake_word_id": wake_word_id, } @@ -295,6 +309,8 @@ class Pipeline: tts_engine: str | None tts_language: str | None tts_voice: str | None + wake_word_entity: str | None + wake_word_id: str | None id: str = field(default_factory=ulid_util.ulid) @@ -316,6 +332,8 @@ def from_json(cls, data: dict[str, Any]) -> Pipeline: tts_engine=data["tts_engine"], tts_language=data["tts_language"], tts_voice=data["tts_voice"], + wake_word_entity=data["wake_word_entity"], + wake_word_id=data["wake_word_id"], ) def to_json(self) -> dict[str, Any]: @@ -331,6 +349,8 @@ def to_json(self) -> dict[str, Any]: "tts_engine": self.tts_engine, "tts_language": self.tts_language, "tts_voice": self.tts_voice, + "wake_word_entity": self.wake_word_entity, + "wake_word_id": self.wake_word_id, } @@ -380,6 +400,60 @@ class WakeWordSettings: """Seconds of audio to buffer before detection and forward to STT.""" +@dataclass(frozen=True) +class AudioSettings: + """Settings for pipeline audio processing.""" + + noise_suppression_level: int = 0 + """Level of noise suppression (0 = disabled, 4 = max)""" + + auto_gain_dbfs: int = 0 + """Amount of automatic gain in dbFS (0 = disabled, 31 = max)""" + + volume_multiplier: float = 1.0 + """Multiplier used directly on PCM samples (1.0 = no change, 2.0 = twice as loud)""" + + is_vad_enabled: bool = True + """True if VAD is used to determine the end of the voice command.""" + + is_chunking_enabled: bool = True + """True if audio is automatically split into 10 ms chunks (required for VAD, etc.)""" + + def __post_init__(self) -> None: + """Verify settings post-initialization.""" + if (self.noise_suppression_level < 0) or (self.noise_suppression_level > 4): + raise ValueError("noise_suppression_level must be in [0, 4]") + + if (self.auto_gain_dbfs < 0) or (self.auto_gain_dbfs > 31): + raise ValueError("auto_gain_dbfs must be in [0, 31]") + + if self.needs_processor and (not self.is_chunking_enabled): + raise ValueError("Chunking must be enabled for audio processing") + + @property + def needs_processor(self) -> bool: + """True if an audio processor is needed.""" + return ( + self.is_vad_enabled + or (self.noise_suppression_level > 0) + or (self.auto_gain_dbfs > 0) + ) + + +@dataclass(frozen=True, slots=True) +class ProcessedAudioChunk: + """Processed audio chunk and metadata.""" + + audio: bytes + """Raw PCM audio @ 16Khz with 16-bit mono samples""" + + timestamp_ms: int + """Timestamp relative to start of audio stream (milliseconds)""" + + is_speech: bool | None + """True if audio chunk likely contains speech, False if not, None if unknown""" + + @dataclass class PipelineRun: """Running context for a pipeline.""" @@ -395,13 +469,16 @@ class PipelineRun: intent_agent: str | None = None tts_audio_output: str | None = None wake_word_settings: WakeWordSettings | None = None + audio_settings: AudioSettings = field(default_factory=AudioSettings) id: str = field(default_factory=ulid_util.ulid) - stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False) - tts_engine: str = field(init=False) + stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False) + tts_engine: str = field(init=False, repr=False) tts_options: dict | None = field(init=False, default=None) - wake_word_engine: str = field(init=False) - wake_word_provider: wake_word.WakeWordDetectionEntity = field(init=False) + wake_word_entity_id: str = field(init=False, repr=False) + wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False, repr=False) + + abort_wake_word_detection: bool = field(init=False, default=False) debug_recording_thread: Thread | None = None """Thread that records audio to debug_recording_dir""" @@ -409,6 +486,12 @@ class PipelineRun: debug_recording_queue: Queue[str | bytes | None] | None = None """Queue to communicate with debug recording thread""" + audio_processor: AudioProcessor | None = None + """VAD/noise suppression/auto gain""" + + audio_processor_buffer: AudioBuffer = field(init=False, repr=False) + """Buffer used when splitting audio into chunks for audio processing""" + def __post_init__(self) -> None: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language @@ -420,21 +503,30 @@ def __post_init__(self) -> None: raise InvalidPipelineStagesError(self.start_stage, self.end_stage) pipeline_data: PipelineData = self.hass.data[DOMAIN] - if self.pipeline.id not in pipeline_data.pipeline_runs: - pipeline_data.pipeline_runs[self.pipeline.id] = LimitedSizeDict( + if self.pipeline.id not in pipeline_data.pipeline_debug: + pipeline_data.pipeline_debug[self.pipeline.id] = LimitedSizeDict( size_limit=STORED_PIPELINE_RUNS ) - pipeline_data.pipeline_runs[self.pipeline.id][self.id] = PipelineRunDebug() + pipeline_data.pipeline_debug[self.pipeline.id][self.id] = PipelineRunDebug() + pipeline_data.pipeline_runs.add_run(self) + + # Initialize with audio settings + self.audio_processor_buffer = AudioBuffer(AUDIO_PROCESSOR_BYTES) + if self.audio_settings.needs_processor: + self.audio_processor = AudioProcessor( + self.audio_settings.auto_gain_dbfs, + self.audio_settings.noise_suppression_level, + ) @callback def process_event(self, event: PipelineEvent) -> None: """Log an event and call listener.""" self.event_callback(event) pipeline_data: PipelineData = self.hass.data[DOMAIN] - if self.id not in pipeline_data.pipeline_runs[self.pipeline.id]: + if self.id not in pipeline_data.pipeline_debug[self.pipeline.id]: # This run has been evicted from the logged pipeline runs already return - pipeline_data.pipeline_runs[self.pipeline.id][self.id].events.append(event) + pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event) def start(self, device_id: str | None) -> None: """Emit run start event.""" @@ -461,31 +553,36 @@ async def end(self) -> None: ) ) + pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data.pipeline_runs.remove_run(self) + async def prepare_wake_word_detection(self) -> None: """Prepare wake-word-detection.""" - engine = wake_word.async_default_engine(self.hass) - if engine is None: + entity_id = self.pipeline.wake_word_entity or wake_word.async_default_entity( + self.hass + ) + if entity_id is None: raise WakeWordDetectionError( code="wake-engine-missing", message="No wake word engine", ) - wake_word_provider = wake_word.async_get_wake_word_detection_entity( - self.hass, engine + wake_word_entity = wake_word.async_get_wake_word_detection_entity( + self.hass, entity_id ) - if wake_word_provider is None: + if wake_word_entity is None: raise WakeWordDetectionError( code="wake-provider-missing", - message=f"No wake-word-detection provider for: {engine}", + message=f"No wake-word-detection provider for: {entity_id}", ) - self.wake_word_engine = engine - self.wake_word_provider = wake_word_provider + self.wake_word_entity_id = entity_id + self.wake_word_entity = wake_word_entity async def wake_word_detection( self, - stream: AsyncIterable[bytes], - audio_chunks_for_stt: list[bytes], + stream: AsyncIterable[ProcessedAudioChunk], + audio_chunks_for_stt: list[ProcessedAudioChunk], ) -> wake_word.DetectionResult | None: """Run wake-word-detection portion of pipeline. Returns detection result.""" metadata_dict = asdict( @@ -506,14 +603,14 @@ async def wake_word_detection( PipelineEvent( PipelineEventType.WAKE_WORD_START, { - "engine": self.wake_word_engine, + "entity_id": self.wake_word_entity_id, "metadata": metadata_dict, }, ) ) if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(f"00_wake-{self.wake_word_engine}") + self.debug_recording_queue.put_nowait(f"00_wake-{self.wake_word_entity_id}") wake_word_settings = self.wake_word_settings or WakeWordSettings() @@ -526,27 +623,31 @@ async def wake_word_detection( # Audio chunk buffer. This audio will be forwarded to speech-to-text # after wake-word-detection. - num_audio_bytes_to_buffer = int( - wake_word_settings.audio_seconds_to_buffer * 16000 * 2 # 16-bit @ 16Khz + num_audio_chunks_to_buffer = int( + (wake_word_settings.audio_seconds_to_buffer * 16000) + / AUDIO_PROCESSOR_SAMPLES ) - stt_audio_buffer: RingBuffer | None = None - if num_audio_bytes_to_buffer > 0: - stt_audio_buffer = RingBuffer(num_audio_bytes_to_buffer) + stt_audio_buffer: deque[ProcessedAudioChunk] | None = None + if num_audio_chunks_to_buffer > 0: + stt_audio_buffer = deque(maxlen=num_audio_chunks_to_buffer) try: # Detect wake word(s) - result = await self.wake_word_provider.async_process_audio_stream( + result = await self.wake_word_entity.async_process_audio_stream( self._wake_word_audio_stream( audio_stream=stream, stt_audio_buffer=stt_audio_buffer, wake_word_vad=wake_word_vad, - ) + ), + self.pipeline.wake_word_id, ) if stt_audio_buffer is not None: # All audio kept from right before the wake word was detected as # a single chunk. - audio_chunks_for_stt.append(stt_audio_buffer.getvalue()) + audio_chunks_for_stt.extend(stt_audio_buffer) + except WakeWordDetectionAborted: + raise except WakeWordTimeoutError: _LOGGER.debug("Timeout during wake word detection") raise @@ -570,7 +671,11 @@ async def wake_word_detection( # speech-to-text so the user does not have to pause before # speaking the voice command. for chunk_ts in result.queued_audio: - audio_chunks_for_stt.append(chunk_ts[0]) + audio_chunks_for_stt.append( + ProcessedAudioChunk( + audio=chunk_ts[0], timestamp_ms=chunk_ts[1], is_speech=False + ) + ) wake_word_output = asdict(result) @@ -588,8 +693,8 @@ async def wake_word_detection( async def _wake_word_audio_stream( self, - audio_stream: AsyncIterable[bytes], - stt_audio_buffer: RingBuffer | None, + audio_stream: AsyncIterable[ProcessedAudioChunk], + stt_audio_buffer: deque[ProcessedAudioChunk] | None, wake_word_vad: VoiceActivityTimeout | None, sample_rate: int = 16000, sample_width: int = 2, @@ -599,25 +704,27 @@ async def _wake_word_audio_stream( Adds audio to a ring buffer that will be forwarded to speech-to-text after detection. Times out if VAD detects enough silence. """ - ms_per_sample = sample_rate // 1000 - timestamp_ms = 0 + chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate async for chunk in audio_stream: + if self.abort_wake_word_detection: + raise WakeWordDetectionAborted + if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(chunk) + self.debug_recording_queue.put_nowait(chunk.audio) - yield chunk, timestamp_ms - timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + yield chunk.audio, chunk.timestamp_ms # Wake-word-detection occurs *after* the wake word was actually # spoken. Keeping audio right before detection allows the voice # command to be spoken immediately after the wake word. if stt_audio_buffer is not None: - stt_audio_buffer.put(chunk) + stt_audio_buffer.append(chunk) - if (wake_word_vad is not None) and (not wake_word_vad.process(chunk)): - raise WakeWordTimeoutError( - code="wake-word-timeout", message="Wake word was not detected" - ) + if wake_word_vad is not None: + if not wake_word_vad.process(chunk_seconds, chunk.is_speech): + raise WakeWordTimeoutError( + code="wake-word-timeout", message="Wake word was not detected" + ) async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: """Prepare speech-to-text.""" @@ -650,7 +757,7 @@ async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: async def speech_to_text( self, metadata: stt.SpeechMetadata, - stream: AsyncIterable[bytes], + stream: AsyncIterable[ProcessedAudioChunk], ) -> str: """Run speech-to-text portion of pipeline. Returns the spoken text.""" if isinstance(self.stt_provider, stt.Provider): @@ -674,11 +781,13 @@ async def speech_to_text( try: # Transcribe audio stream + stt_vad: VoiceCommandSegmenter | None = None + if self.audio_settings.is_vad_enabled: + stt_vad = VoiceCommandSegmenter() + result = await self.stt_provider.async_process_audio_stream( metadata, - self._speech_to_text_stream( - audio_stream=stream, stt_vad=VoiceCommandSegmenter() - ), + self._speech_to_text_stream(audio_stream=stream, stt_vad=stt_vad), ) except Exception as src_error: _LOGGER.exception("Unexpected error during speech-to-text") @@ -715,26 +824,25 @@ async def speech_to_text( async def _speech_to_text_stream( self, - audio_stream: AsyncIterable[bytes], + audio_stream: AsyncIterable[ProcessedAudioChunk], stt_vad: VoiceCommandSegmenter | None, sample_rate: int = 16000, sample_width: int = 2, ) -> AsyncGenerator[bytes, None]: """Yield audio chunks until VAD detects silence or speech-to-text completes.""" - ms_per_sample = sample_rate // 1000 + chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate sent_vad_start = False - timestamp_ms = 0 async for chunk in audio_stream: if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(chunk) + self.debug_recording_queue.put_nowait(chunk.audio) if stt_vad is not None: - if not stt_vad.process(chunk): + if not stt_vad.process(chunk_seconds, chunk.is_speech): # Silence detected at the end of voice command self.process_event( PipelineEvent( PipelineEventType.STT_VAD_END, - {"timestamp": timestamp_ms}, + {"timestamp": chunk.timestamp_ms}, ) ) break @@ -744,13 +852,12 @@ async def _speech_to_text_stream( self.process_event( PipelineEvent( PipelineEventType.STT_VAD_START, - {"timestamp": timestamp_ms}, + {"timestamp": chunk.timestamp_ms}, ) ) sent_vad_start = True - yield chunk - timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + yield chunk.audio async def prepare_recognize_intent(self) -> None: """Prepare recognizing an intent.""" @@ -961,6 +1068,87 @@ async def _stop_debug_recording_thread(self) -> None: self.debug_recording_queue = None self.debug_recording_thread = None + async def process_volume_only( + self, + audio_stream: AsyncIterable[bytes], + sample_rate: int = 16000, + sample_width: int = 2, + ) -> AsyncGenerator[ProcessedAudioChunk, None]: + """Apply volume transformation only (no VAD/audio enhancements) with optional chunking.""" + ms_per_sample = sample_rate // 1000 + ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample + timestamp_ms = 0 + + async for chunk in audio_stream: + if self.audio_settings.volume_multiplier != 1.0: + chunk = _multiply_volume(chunk, self.audio_settings.volume_multiplier) + + if self.audio_settings.is_chunking_enabled: + # 10 ms chunking + for chunk_10ms in chunk_samples( + chunk, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer + ): + yield ProcessedAudioChunk( + audio=chunk_10ms, + timestamp_ms=timestamp_ms, + is_speech=None, # no VAD + ) + timestamp_ms += ms_per_chunk + else: + # No chunking + yield ProcessedAudioChunk( + audio=chunk, + timestamp_ms=timestamp_ms, + is_speech=None, # no VAD + ) + timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + + async def process_enhance_audio( + self, + audio_stream: AsyncIterable[bytes], + sample_rate: int = 16000, + sample_width: int = 2, + ) -> AsyncGenerator[ProcessedAudioChunk, None]: + """Split audio into 10 ms chunks and apply VAD/noise suppression/auto gain/volume transformation.""" + assert self.audio_processor is not None + + ms_per_sample = sample_rate // 1000 + ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample + timestamp_ms = 0 + + async for dirty_samples in audio_stream: + if self.audio_settings.volume_multiplier != 1.0: + # Static gain + dirty_samples = _multiply_volume( + dirty_samples, self.audio_settings.volume_multiplier + ) + + # Split into 10ms chunks for audio enhancements/VAD + for dirty_10ms_chunk in chunk_samples( + dirty_samples, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer + ): + ap_result = self.audio_processor.Process10ms(dirty_10ms_chunk) + yield ProcessedAudioChunk( + audio=ap_result.audio, + timestamp_ms=timestamp_ms, + is_speech=ap_result.is_speech, + ) + + timestamp_ms += ms_per_chunk + + +def _multiply_volume(chunk: bytes, volume_multiplier: float) -> bytes: + """Multiplies 16-bit PCM samples by a constant.""" + + def _clamp(val: float) -> float: + """Clamp to signed 16-bit.""" + return max(-32768, min(32767, val)) + + return array.array( + "h", + (int(_clamp(value * volume_multiplier)) for value in array.array("h", chunk)), + ).tobytes() + def _pipeline_debug_recording_thread_proc( run_recording_dir: Path, @@ -1026,18 +1214,26 @@ async def execute(self) -> None: """Run pipeline.""" self.run.start(device_id=self.device_id) current_stage: PipelineStage | None = self.run.start_stage - stt_audio_buffer: list[bytes] = [] + stt_audio_buffer: list[ProcessedAudioChunk] = [] + stt_processed_stream: AsyncIterable[ProcessedAudioChunk] | None = None + + if self.stt_stream is not None: + if self.run.audio_settings.needs_processor: + # VAD/noise suppression/auto gain/volume + stt_processed_stream = self.run.process_enhance_audio(self.stt_stream) + else: + # Volume multiplier only + stt_processed_stream = self.run.process_volume_only(self.stt_stream) try: if current_stage == PipelineStage.WAKE_WORD: # wake-word-detection - assert self.stt_stream is not None + assert stt_processed_stream is not None detect_result = await self.run.wake_word_detection( - self.stt_stream, stt_audio_buffer + stt_processed_stream, stt_audio_buffer ) if detect_result is None: # No wake word. Abort the rest of the pipeline. - await self.run.end() return current_stage = PipelineStage.STT @@ -1046,28 +1242,30 @@ async def execute(self) -> None: intent_input = self.intent_input if current_stage == PipelineStage.STT: assert self.stt_metadata is not None - assert self.stt_stream is not None + assert stt_processed_stream is not None - stt_stream = self.stt_stream + stt_input_stream = stt_processed_stream if stt_audio_buffer: # Send audio in the buffer first to speech-to-text, then move on to stt_stream. # This is basically an async itertools.chain. - async def buffer_then_audio_stream() -> AsyncGenerator[bytes, None]: + async def buffer_then_audio_stream() -> AsyncGenerator[ + ProcessedAudioChunk, None + ]: # Buffered audio for chunk in stt_audio_buffer: yield chunk # Streamed audio - assert self.stt_stream is not None - async for chunk in self.stt_stream: + assert stt_processed_stream is not None + async for chunk in stt_processed_stream: yield chunk - stt_stream = buffer_then_audio_stream() + stt_input_stream = buffer_then_audio_stream() intent_input = await self.run.speech_to_text( self.stt_metadata, - stt_stream, + stt_input_stream, ) current_stage = PipelineStage.INTENT @@ -1362,13 +1560,48 @@ async def ws_set_preferred_item( connection.send_result(msg["id"]) -@dataclass +class PipelineRuns: + """Class managing pipelineruns.""" + + def __init__(self, pipeline_store: PipelineStorageCollection) -> None: + """Initialize.""" + self._pipeline_runs: dict[str, list[PipelineRun]] = {} + self._pipeline_store = pipeline_store + pipeline_store.async_add_listener(self._change_listener) + + def add_run(self, pipeline_run: PipelineRun) -> None: + """Add pipeline run.""" + pipeline_id = pipeline_run.pipeline.id + if pipeline_id not in self._pipeline_runs: + self._pipeline_runs[pipeline_id] = [] + self._pipeline_runs[pipeline_id].append(pipeline_run) + + def remove_run(self, pipeline_run: PipelineRun) -> None: + """Remove pipeline run.""" + pipeline_id = pipeline_run.pipeline.id + self._pipeline_runs[pipeline_id].remove(pipeline_run) + + async def _change_listener( + self, change_type: str, item_id: str, change: dict + ) -> None: + """Handle pipeline store changes.""" + if change_type != CHANGE_UPDATED: + return + if pipeline_runs := self._pipeline_runs.get(item_id): + # Create a temporary list in case the list is modified while we iterate + for pipeline_run in list(pipeline_runs): + pipeline_run.abort_wake_word_detection = True + + class PipelineData: """Store and debug data stored in hass.data.""" - pipeline_runs: dict[str, LimitedSizeDict[str, PipelineRunDebug]] - pipeline_store: PipelineStorageCollection - pipeline_devices: set[str] = field(default_factory=set, init=False) + def __init__(self, pipeline_store: PipelineStorageCollection) -> None: + """Initialize.""" + self.pipeline_store = pipeline_store + self.pipeline_debug: dict[str, LimitedSizeDict[str, PipelineRunDebug]] = {} + self.pipeline_devices: set[str] = set() + self.pipeline_runs = PipelineRuns(pipeline_store) @dataclass @@ -1382,11 +1615,35 @@ class PipelineRunDebug: ) +class PipelineStore(Store[SerializedPipelineStorageCollection]): + """Store entity registry data.""" + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: SerializedPipelineStorageCollection, + ) -> SerializedPipelineStorageCollection: + """Migrate to the new version.""" + if old_major_version == 1 and old_minor_version < 2: + # Version 1.2 adds wake word configuration + for pipeline in old_data["items"]: + # Populate keys which were introduced before version 1.2 + pipeline.setdefault("wake_word_entity", None) + pipeline.setdefault("wake_word_id", None) + + if old_major_version > 1: + raise NotImplementedError + return old_data + + @singleton(DOMAIN) async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: """Set up the pipeline storage collection.""" pipeline_store = PipelineStorageCollection( - Store(hass, STORAGE_VERSION, STORAGE_KEY) + PipelineStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) ) await pipeline_store.async_load() PipelineStorageCollectionWebsocket( @@ -1396,4 +1653,4 @@ async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: PIPELINE_FIELDS, PIPELINE_FIELDS, ).async_setup(hass) - return PipelineData({}, pipeline_store) + return PipelineData(pipeline_store) diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index 20a048d5621dcd..30fad1c80d6c52 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -1,12 +1,13 @@ """Voice activity detection.""" from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Iterable -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import StrEnum -from typing import Final +from typing import Final, cast -import webrtcvad +from webrtc_noise_gain import AudioProcessor _SAMPLE_RATE: Final = 16000 # Hz _SAMPLE_WIDTH: Final = 2 # bytes @@ -32,6 +33,38 @@ def to_seconds(sensitivity: VadSensitivity | str) -> float: return 1.0 +class VoiceActivityDetector(ABC): + """Base class for voice activity detectors (VAD).""" + + @abstractmethod + def is_speech(self, chunk: bytes) -> bool: + """Return True if audio chunk contains speech.""" + + @property + @abstractmethod + def samples_per_chunk(self) -> int | None: + """Return number of samples per chunk or None if chunking is not required.""" + + +class WebRtcVad(VoiceActivityDetector): + """Voice activity detector based on webrtc.""" + + def __init__(self) -> None: + """Initialize webrtcvad.""" + # Just VAD: no noise suppression or auto gain + self._audio_processor = AudioProcessor(0, 0) + + def is_speech(self, chunk: bytes) -> bool: + """Return True if audio chunk contains speech.""" + result = self._audio_processor.Process10ms(chunk) + return cast(bool, result.is_speech) + + @property + def samples_per_chunk(self) -> int | None: + """Return 10 ms.""" + return int(0.01 * _SAMPLE_RATE) # 10 ms + + class AudioBuffer: """Fixed-sized audio buffer with variable internal length.""" @@ -73,13 +106,7 @@ def __bool__(self) -> bool: @dataclass class VoiceCommandSegmenter: - """Segments an audio stream into voice commands using webrtcvad.""" - - vad_mode: int = 3 - """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" - - vad_samples_per_chunk: int = 480 # 30 ms - """Must be 10, 20, or 30 ms at 16Khz.""" + """Segments an audio stream into voice commands.""" speech_seconds: float = 0.3 """Seconds of speech before voice command has started.""" @@ -108,85 +135,85 @@ class VoiceCommandSegmenter: _reset_seconds_left: float = 0.0 """Seconds left before resetting start/stop time counters.""" - _vad: webrtcvad.Vad = None - _leftover_chunk_buffer: AudioBuffer = field(init=False) - _bytes_per_chunk: int = field(init=False) - _seconds_per_chunk: float = field(init=False) - def __post_init__(self) -> None: - """Initialize VAD.""" - self._vad = webrtcvad.Vad(self.vad_mode) - self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH - self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE - self._leftover_chunk_buffer = AudioBuffer( - self.vad_samples_per_chunk * _SAMPLE_WIDTH - ) + """Reset after initialization.""" self.reset() def reset(self) -> None: """Reset all counters and state.""" - self._leftover_chunk_buffer.clear() self._speech_seconds_left = self.speech_seconds self._silence_seconds_left = self.silence_seconds self._timeout_seconds_left = self.timeout_seconds self._reset_seconds_left = self.reset_seconds self.in_command = False - def process(self, samples: bytes) -> bool: - """Process 16-bit 16Khz mono audio samples. - - Returns False when command is done. - """ - for chunk in chunk_samples( - samples, self._bytes_per_chunk, self._leftover_chunk_buffer - ): - if not self._process_chunk(chunk): - self.reset() - return False - - return True - - @property - def audio_buffer(self) -> bytes: - """Get partial chunk in the audio buffer.""" - return self._leftover_chunk_buffer.bytes() - - def _process_chunk(self, chunk: bytes) -> bool: - """Process a single chunk of 16-bit 16Khz mono audio. + def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: + """Process samples using external VAD. Returns False when command is done. """ - is_speech = self._vad.is_speech(chunk, _SAMPLE_RATE) - - self._timeout_seconds_left -= self._seconds_per_chunk + self._timeout_seconds_left -= chunk_seconds if self._timeout_seconds_left <= 0: + self.reset() return False if not self.in_command: if is_speech: self._reset_seconds_left = self.reset_seconds - self._speech_seconds_left -= self._seconds_per_chunk + self._speech_seconds_left -= chunk_seconds if self._speech_seconds_left <= 0: # Inside voice command self.in_command = True else: # Reset if enough silence - self._reset_seconds_left -= self._seconds_per_chunk + self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: self._speech_seconds_left = self.speech_seconds elif not is_speech: self._reset_seconds_left = self.reset_seconds - self._silence_seconds_left -= self._seconds_per_chunk + self._silence_seconds_left -= chunk_seconds if self._silence_seconds_left <= 0: + self.reset() return False else: # Reset if enough speech - self._reset_seconds_left -= self._seconds_per_chunk + self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: self._silence_seconds_left = self.silence_seconds return True + def process_with_vad( + self, + chunk: bytes, + vad: VoiceActivityDetector, + leftover_chunk_buffer: AudioBuffer | None, + ) -> bool: + """Process an audio chunk using an external VAD. + + A buffer is required if the VAD requires fixed-sized audio chunks (usually the case). + + Returns False when voice command is finished. + """ + if vad.samples_per_chunk is None: + # No chunking + chunk_seconds = (len(chunk) // _SAMPLE_WIDTH) / _SAMPLE_RATE + is_speech = vad.is_speech(chunk) + return self.process(chunk_seconds, is_speech) + + if leftover_chunk_buffer is None: + raise ValueError("leftover_chunk_buffer is required when vad uses chunking") + + # With chunking + seconds_per_chunk = vad.samples_per_chunk / _SAMPLE_RATE + bytes_per_chunk = vad.samples_per_chunk * _SAMPLE_WIDTH + for vad_chunk in chunk_samples(chunk, bytes_per_chunk, leftover_chunk_buffer): + is_speech = vad.is_speech(vad_chunk) + if not self.process(seconds_per_chunk, is_speech): + return False + + return True + @dataclass class VoiceActivityTimeout: @@ -198,73 +225,43 @@ class VoiceActivityTimeout: reset_seconds: float = 0.5 """Seconds of speech before resetting timeout.""" - vad_mode: int = 3 - """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" - - vad_samples_per_chunk: int = 480 # 30 ms - """Must be 10, 20, or 30 ms at 16Khz.""" - _silence_seconds_left: float = 0.0 """Seconds left before considering voice command as stopped.""" _reset_seconds_left: float = 0.0 """Seconds left before resetting start/stop time counters.""" - _vad: webrtcvad.Vad = None - _leftover_chunk_buffer: AudioBuffer = field(init=False) - _bytes_per_chunk: int = field(init=False) - _seconds_per_chunk: float = field(init=False) - def __post_init__(self) -> None: - """Initialize VAD.""" - self._vad = webrtcvad.Vad(self.vad_mode) - self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH - self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE - self._leftover_chunk_buffer = AudioBuffer( - self.vad_samples_per_chunk * _SAMPLE_WIDTH - ) + """Reset after initialization.""" self.reset() def reset(self) -> None: """Reset all counters and state.""" - self._leftover_chunk_buffer.clear() self._silence_seconds_left = self.silence_seconds self._reset_seconds_left = self.reset_seconds - def process(self, samples: bytes) -> bool: - """Process 16-bit 16Khz mono audio samples. + def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: + """Process samples using external VAD. Returns False when timeout is reached. """ - for chunk in chunk_samples( - samples, self._bytes_per_chunk, self._leftover_chunk_buffer - ): - if not self._process_chunk(chunk): - return False - - return True - - def _process_chunk(self, chunk: bytes) -> bool: - """Process a single chunk of 16-bit 16Khz mono audio. - - Returns False when timeout is reached. - """ - if self._vad.is_speech(chunk, _SAMPLE_RATE): + if is_speech: # Speech - self._reset_seconds_left -= self._seconds_per_chunk + self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: # Reset timeout self._silence_seconds_left = self.silence_seconds else: # Silence - self._silence_seconds_left -= self._seconds_per_chunk + self._silence_seconds_left -= chunk_seconds if self._silence_seconds_left <= 0: # Timeout reached + self.reset() return False # Slowly build reset counter back up self._reset_seconds_left = min( - self.reset_seconds, self._reset_seconds_left + self._seconds_per_chunk + self.reset_seconds, self._reset_seconds_left + chunk_seconds ) return True diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 6d8fd02a21730f..f57424223cf140 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -18,6 +18,7 @@ from .const import DOMAIN from .error import PipelineNotFound from .pipeline import ( + AudioSettings, PipelineData, PipelineError, PipelineEvent, @@ -71,6 +72,13 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: vol.Optional("audio_seconds_to_buffer"): vol.Any( float, int ), + # Audio enhancement + vol.Optional("noise_suppression_level"): int, + vol.Optional("auto_gain_dbfs"): int, + vol.Optional("volume_multiplier"): float, + # Advanced use cases/testing + vol.Optional("no_vad"): bool, + vol.Optional("no_chunking"): bool, } }, extra=vol.ALLOW_EXTRA, @@ -115,6 +123,7 @@ async def websocket_run( handler_id: int | None = None unregister_handler: Callable[[], None] | None = None wake_word_settings: WakeWordSettings | None = None + audio_settings: AudioSettings | None = None # Arguments to PipelineInput input_args: dict[str, Any] = { @@ -124,13 +133,14 @@ async def websocket_run( if start_stage in (PipelineStage.WAKE_WORD, PipelineStage.STT): # Audio pipeline that will receive audio as binary websocket messages + msg_input = msg["input"] audio_queue: asyncio.Queue[bytes] = asyncio.Queue() - incoming_sample_rate = msg["input"]["sample_rate"] + incoming_sample_rate = msg_input["sample_rate"] if start_stage == PipelineStage.WAKE_WORD: wake_word_settings = WakeWordSettings( timeout=msg["input"].get("timeout", DEFAULT_WAKE_WORD_TIMEOUT), - audio_seconds_to_buffer=msg["input"].get("audio_seconds_to_buffer", 0), + audio_seconds_to_buffer=msg_input.get("audio_seconds_to_buffer", 0), ) async def stt_stream() -> AsyncGenerator[bytes, None]: @@ -166,6 +176,15 @@ def handle_binary( channel=stt.AudioChannels.CHANNEL_MONO, ) input_args["stt_stream"] = stt_stream() + + # Audio settings + audio_settings = AudioSettings( + noise_suppression_level=msg_input.get("noise_suppression_level", 0), + auto_gain_dbfs=msg_input.get("auto_gain_dbfs", 0), + volume_multiplier=msg_input.get("volume_multiplier", 1.0), + is_vad_enabled=not msg_input.get("no_vad", False), + is_chunking_enabled=not msg_input.get("no_chunking", False), + ) elif start_stage == PipelineStage.INTENT: # Input to conversation agent input_args["intent_input"] = msg["input"]["text"] @@ -185,6 +204,7 @@ def handle_binary( "timeout": timeout, }, wake_word_settings=wake_word_settings, + audio_settings=audio_settings or AudioSettings(), ) pipeline_input = PipelineInput(**input_args) @@ -238,18 +258,18 @@ def websocket_list_runs( pipeline_data: PipelineData = hass.data[DOMAIN] pipeline_id = msg["pipeline_id"] - if pipeline_id not in pipeline_data.pipeline_runs: + if pipeline_id not in pipeline_data.pipeline_debug: connection.send_result(msg["id"], {"pipeline_runs": []}) return - pipeline_runs = pipeline_data.pipeline_runs[pipeline_id] + pipeline_debug = pipeline_data.pipeline_debug[pipeline_id] connection.send_result( msg["id"], { "pipeline_runs": [ {"pipeline_run_id": id, "timestamp": pipeline_run.timestamp} - for id, pipeline_run in pipeline_runs.items() + for id, pipeline_run in pipeline_debug.items() ] }, ) @@ -274,7 +294,7 @@ def websocket_get_run( pipeline_id = msg["pipeline_id"] pipeline_run_id = msg["pipeline_run_id"] - if pipeline_id not in pipeline_data.pipeline_runs: + if pipeline_id not in pipeline_data.pipeline_debug: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_FOUND, @@ -282,9 +302,9 @@ def websocket_get_run( ) return - pipeline_runs = pipeline_data.pipeline_runs[pipeline_id] + pipeline_debug = pipeline_data.pipeline_debug[pipeline_id] - if pipeline_run_id not in pipeline_runs: + if pipeline_run_id not in pipeline_debug: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_FOUND, @@ -294,7 +314,7 @@ def websocket_get_run( connection.send_result( msg["id"], - {"events": pipeline_runs[pipeline_run_id].events}, + {"events": pipeline_debug[pipeline_run_id].events}, ) diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index 6ffba5f13da938..cf7b48412a7f68 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -5,7 +5,7 @@ from auroranoaa import AuroraForecast from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platform +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -29,11 +29,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: longitude = conf[CONF_LONGITUDE] latitude = conf[CONF_LATITUDE] threshold = options.get(CONF_THRESHOLD, DEFAULT_THRESHOLD) - name = conf[CONF_NAME] coordinator = AuroraDataUpdateCoordinator( hass=hass, - name=name, api=api, latitude=latitude, longitude=longitude, diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index bbd0768e74a77e..8fa4b28575882b 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for SpaceX Launches and Starman.""" +"""Config flow for Aurora.""" from __future__ import annotations import logging @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( @@ -16,7 +16,7 @@ SchemaOptionsFlowHandler, ) -from .const import CONF_THRESHOLD, DEFAULT_NAME, DEFAULT_THRESHOLD, DOMAIN +from .const import CONF_THRESHOLD, DEFAULT_THRESHOLD, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -50,7 +50,6 @@ async def async_step_user(self, user_input=None): errors = {} if user_input is not None: - name = user_input[CONF_NAME] longitude = user_input[CONF_LONGITUDE] latitude = user_input[CONF_LATITUDE] @@ -70,7 +69,7 @@ async def async_step_user(self, user_input=None): ) self._abort_if_unique_id_configured() return self.async_create_entry( - title=f"Aurora - {name}", data=user_input + title="Aurora visibility", data=user_input ) return self.async_show_form( @@ -78,13 +77,11 @@ async def async_step_user(self, user_input=None): data_schema=self.add_suggested_values_to_schema( vol.Schema( { - vol.Required(CONF_NAME): str, vol.Required(CONF_LONGITUDE): cv.longitude, vol.Required(CONF_LATITUDE): cv.latitude, } ), { - CONF_NAME: DEFAULT_NAME, CONF_LONGITUDE: self.hass.config.longitude, CONF_LATITUDE: self.hass.config.latitude, }, diff --git a/homeassistant/components/aurora/const.py b/homeassistant/components/aurora/const.py index 419a3c946e644a..fef0b5e6352825 100644 --- a/homeassistant/components/aurora/const.py +++ b/homeassistant/components/aurora/const.py @@ -6,4 +6,3 @@ CONF_THRESHOLD = "forecast_threshold" DEFAULT_THRESHOLD = 75 ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric Administration" -DEFAULT_NAME = "Aurora Visibility" diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index 0ab1be00902534..9d4eb0aa681248 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -18,7 +18,6 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, - name: str, api: AuroraForecast, latitude: float, longitude: float, @@ -29,12 +28,11 @@ def __init__( super().__init__( hass=hass, logger=_LOGGER, - name=name, + name="Aurora", update_interval=timedelta(minutes=5), ) self.api = api - self.name = name self.latitude = int(latitude) self.longitude = int(longitude) self.threshold = int(threshold) diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index a52f523f667949..1b7dfbe88e3a0e 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -29,14 +29,9 @@ def __init__( self._attr_translation_key = translation_key self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" self._attr_icon = icon - - @property - def device_info(self) -> DeviceInfo: - """Define the device based on name.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(self.unique_id))}, + identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="NOAA", model="Aurora Visibility Sensor", - name=self.coordinator.name, ) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 455619182abdb7..240610e48680d4 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -131,6 +131,9 @@ def _async_scanner_watchdog(self, now: datetime.datetime) -> None: self.name, SCANNER_WATCHDOG_TIMEOUT, ) + self.scanning = False + return + self.scanning = not self._connecting @contextmanager def connecting(self) -> Generator[None, None, None]: @@ -302,6 +305,7 @@ def _async_on_advertisement( advertisement_monotonic_time: float, ) -> None: """Call the registered callback.""" + self.scanning = not self._connecting self._last_detection = advertisement_monotonic_time try: prev_discovery = self._discovered_device_advertisement_datas[address] diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 80fbe2d49a5f57..d69558fe7fdbba 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -18,7 +18,7 @@ ) from homeassistant import config_entries -from homeassistant.components.logger import EVENT_LOGGING_CHANGED +from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import ( CALLBACK_TYPE, Event, diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index def08cb914c359..2e2d6fa45ed6d2 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -3,7 +3,7 @@ "name": "Bluetooth", "codeowners": ["@bdraco"], "config_flow": true, - "dependencies": ["logger", "usb"], + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/bluetooth", "iot_class": "local_push", "loggers": [ @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.12.0", - "dbus-fast==2.9.0" + "dbus-fast==2.11.0" ] } diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index eb3ce11b644a94..896d9dc7958d55 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -329,6 +329,9 @@ def _async_scanner_watchdog(self, now: datetime) -> None: self.name, SCANNER_WATCHDOG_TIMEOUT, ) + # Immediately mark the scanner as not scanning + # since the restart task will have to wait for the lock + self.scanning = False self.hass.async_create_task(self._async_restart_scanner()) async def _async_restart_scanner(self) -> None: diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py index 796698c6a4c881..57797ca592a02d 100644 --- a/homeassistant/components/broadlink/light.py +++ b/homeassistant/components/broadlink/light.py @@ -6,7 +6,6 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ColorMode, @@ -113,16 +112,6 @@ async def async_turn_on(self, **kwargs: Any) -> None: state["colortemp"] = (color_temp - 153) * 100 + 2700 state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE - elif ATTR_COLOR_MODE in kwargs: - color_mode = kwargs[ATTR_COLOR_MODE] - if color_mode == ColorMode.HS: - state["bulb_colormode"] = BROADLINK_COLOR_MODE_RGB - elif color_mode == ColorMode.COLOR_TEMP: - state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE - else: - # Scenes are not yet supported. - state["bulb_colormode"] = BROADLINK_COLOR_MODE_SCENES - await self._async_set_state(state) async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 0a49c0b6ed6358..c11ec47b2e5633 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -345,14 +345,16 @@ def should_2fa(self, state: State) -> bool: assistant_options = settings.get(CLOUD_GOOGLE, {}) return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) - async def async_report_state(self, message: Any, agent_user_id: str) -> None: + async def async_report_state( + self, message: Any, agent_user_id: str, event_id: str | None = None + ) -> None: """Send a state report to Google.""" try: await self._cloud.google_report_state.async_send_message(message) except ErrorResponse as err: _LOGGER.warning("Error reporting state - %s: %s", err.code, err.message) - async def _async_request_sync_devices(self, agent_user_id: str) -> int: + async def _async_request_sync_devices(self, agent_user_id: str) -> HTTPStatus | int: """Trigger a sync with Google.""" if self._sync_entities_lock.locked(): return HTTPStatus.OK diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index dfb78326abe39b..c210d989c04152 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from datetime import timedelta +from json import JSONDecodeError import logging from typing import Any, cast @@ -68,6 +69,10 @@ def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalRespons wait=False, ) + except JSONDecodeError as err: + # raise occasional occurring json decoding errors as CO2Error so the data update coordinator retries it + raise CO2Error from err + except ValueError as err: err_str = str(err) diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index fb04ebb76a45d6..af460f819cddcc 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -13,6 +13,7 @@ DOMAIN as LIGHT_DOMAIN, LIGHT_TURN_ON_SCHEMA, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import aiohttp_client @@ -58,8 +59,20 @@ def _get_color(file_handler) -> tuple: return color -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Set up services for color_extractor integration.""" +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Color extractor component.""" + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={} + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Load a config entry.""" async def async_handle_service(service_call: ServiceCall) -> None: """Decide which color_extractor method to call based on service.""" diff --git a/homeassistant/components/color_extractor/config_flow.py b/homeassistant/components/color_extractor/config_flow.py new file mode 100644 index 00000000000000..32b803d14f95a3 --- /dev/null +++ b/homeassistant/components/color_extractor/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow to configure the Color extractor integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DEFAULT_NAME, DOMAIN + + +class ColorExtractorConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Color extractor.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + return self.async_create_entry(title=DEFAULT_NAME, data={}) + + return self.async_show_form(step_id="user") + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle import from configuration.yaml.""" + result = await self.async_step_user(user_input) + if result["type"] == FlowResultType.CREATE_ENTRY: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Color extractor", + }, + ) + else: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Color extractor", + }, + ) + return result diff --git a/homeassistant/components/color_extractor/const.py b/homeassistant/components/color_extractor/const.py index a6c59ea434bd8a..e783dcb533d7eb 100644 --- a/homeassistant/components/color_extractor/const.py +++ b/homeassistant/components/color_extractor/const.py @@ -3,5 +3,6 @@ ATTR_URL = "color_extract_url" DOMAIN = "color_extractor" +DEFAULT_NAME = "Color extractor" SERVICE_TURN_ON = "turn_on" diff --git a/homeassistant/components/color_extractor/manifest.json b/homeassistant/components/color_extractor/manifest.json index 07e9b43a5e512d..c87ac2540a6229 100644 --- a/homeassistant/components/color_extractor/manifest.json +++ b/homeassistant/components/color_extractor/manifest.json @@ -2,7 +2,7 @@ "domain": "color_extractor", "name": "ColorExtractor", "codeowners": ["@GenericStudent"], - "config_flow": false, + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/color_extractor", "requirements": ["colorthief==0.2.1"] } diff --git a/homeassistant/components/color_extractor/strings.json b/homeassistant/components/color_extractor/strings.json index 3dc02f5603016c..aa5fd5f4ef7630 100644 --- a/homeassistant/components/color_extractor/strings.json +++ b/homeassistant/components/color_extractor/strings.json @@ -1,4 +1,20 @@ { + "config": { + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour configuration is already imported.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + }, "services": { "turn_on": { "name": "[%key:common::action::turn_on%]", diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 2c73922582cd1a..4a10507280260a 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -7,7 +7,7 @@ from .const import DOMAIN from .coordinator import ComelitSerialBridge -PLATFORMS = [Platform.LIGHT] +PLATFORMS = [Platform.COVER, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index df1d745ce8a540..a9c281c10c0e56 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -71,16 +71,14 @@ def platform_device_info( async def _async_update_data(self) -> dict[str, Any]: """Update router data.""" _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) + logged = False try: logged = await self.api.login() except (asyncio.exceptions.TimeoutError, aiohttp.ClientConnectorError) as err: _LOGGER.warning("Connection error for %s", self._host) raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + finally: + if not logged: + raise ConfigEntryAuthFailed - if not logged: - raise ConfigEntryAuthFailed - - devices_data = await self.api.get_all_devices() - await self.api.logout() - - return devices_data + return await self.api.get_all_devices() diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py new file mode 100644 index 00000000000000..0135fa3984a665 --- /dev/null +++ b/homeassistant/components/comelit/cover.py @@ -0,0 +1,101 @@ +"""Support for covers.""" +from __future__ import annotations + +from typing import Any + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import COVER, COVER_CLOSE, COVER_OPEN, COVER_STATUS + +from homeassistant.components.cover import CoverDeviceClass, CoverEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ComelitSerialBridge + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit covers.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + async_add_entities( + ComelitCoverEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[COVER].values() + ) + + +class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): + """Cover device.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + ) -> None: + """Init cover entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device, COVER) + # Device doesn't provide a status so we assume CLOSE at startup + self._last_action = COVER_STATUS.index("closing") + + def _current_action(self, action: str) -> bool: + """Return the current cover action.""" + is_moving = self.device_status == COVER_STATUS.index(action) + if is_moving: + self._last_action = COVER_STATUS.index(action) + return is_moving + + @property + def device_status(self) -> int: + """Return current device status.""" + return self.coordinator.data[COVER][self._device.index].status + + @property + def is_closed(self) -> bool: + """Return True if cover is closed.""" + if self.device_status != COVER_STATUS.index("stopped"): + return False + + return bool(self._last_action == COVER_STATUS.index("closing")) + + @property + def is_closing(self) -> bool: + """Return if the cover is closing.""" + return self._current_action("closing") + + @property + def is_opening(self) -> bool: + """Return if the cover is opening.""" + return self._current_action("opening") + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + await self._api.cover_move(self._device.index, COVER_CLOSE) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open cover.""" + await self._api.cover_move(self._device.index, COVER_OPEN) + + async def async_stop_cover(self, **_kwargs: Any) -> None: + """Stop the cover.""" + if not self.is_closing and not self.is_opening: + return + + action = COVER_OPEN if self.is_closing else COVER_CLOSE + await self._api.cover_move(self._device.index, action) diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index a4a534025f0e39..a59422f7b046f9 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -36,19 +36,19 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): """Light device.""" _attr_has_entity_name = True + _attr_name = None def __init__( self, coordinator: ComelitSerialBridge, device: ComelitSerialBridgeObject, - config_entry_unique_id: str, + config_entry_entry_id: str, ) -> None: """Init light entity.""" self._api = coordinator.api self._device = device super().__init__(coordinator) - self._attr_name = device.name - self._attr_unique_id = f"{config_entry_unique_id}-{device.index}" + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = self.coordinator.platform_device_info(device, LIGHT) async def _light_set_state(self, state: int) -> None: diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index ee8764348259f2..3e49996e50e76a 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.0.8"] + "requirements": ["aiocomelit==0.0.9"] } diff --git a/homeassistant/components/cpuspeed/manifest.json b/homeassistant/components/cpuspeed/manifest.json index a53c34fb0de29f..ff3a41d9c095e1 100644 --- a/homeassistant/components/cpuspeed/manifest.json +++ b/homeassistant/components/cpuspeed/manifest.json @@ -1,10 +1,10 @@ { "domain": "cpuspeed", "name": "CPU Speed", - "codeowners": ["@fabaff", "@frenck"], + "codeowners": ["@fabaff"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cpuspeed", "integration_type": "device", "iot_class": "local_push", - "requirements": ["py-cpuinfo==8.0.0"] + "requirements": ["py-cpuinfo==9.0.0"] } diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index b428018cd9ec36..7c12a2d8777b38 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant import util +from homeassistant.backports.functools import cached_property from homeassistant.components import zone from homeassistant.config import async_log_exception, load_yaml_config_file from homeassistant.const import ( @@ -262,7 +263,7 @@ class DeviceTrackerPlatform: platform: ModuleType = attr.ib() config: dict = attr.ib() - @property + @cached_property def type(self) -> str | None: """Return platform type.""" methods, platform_type = self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 53bda449465e62..e269d75e0f6348 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.35.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.36.1", "getmac==0.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index d7a72a5341113f..0d07eb0c042358 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.35.1"], + "requirements": ["async-upnp-client==0.36.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/dormakaba_dkey/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json index 7a4f6b9d905e67..52e68b7521c729 100644 --- a/homeassistant/components/dormakaba_dkey/manifest.json +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["py-dormakaba-dkey==1.0.4"] + "requirements": ["py-dormakaba-dkey==1.0.5"] } diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py index 4c8060b468dd84..d9d890c28f3060 100644 --- a/homeassistant/components/duotecno/__init__.py +++ b/homeassistant/components/duotecno/__init__.py @@ -11,7 +11,13 @@ from .const import DOMAIN -PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.COVER, Platform.LIGHT] +PLATFORMS: list[Platform] = [ + Platform.SWITCH, + Platform.COVER, + Platform.LIGHT, + Platform.CLIMATE, + Platform.BINARY_SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py new file mode 100644 index 00000000000000..a1638ce4055b47 --- /dev/null +++ b/homeassistant/components/duotecno/binary_sensor.py @@ -0,0 +1,34 @@ +"""Support for Duotecno binary sensors.""" + +from duotecno.unit import ControlUnit + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import DuotecnoEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Duotecno binary sensor on config_entry.""" + cntrl = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + DuotecnoBinarySensor(channel) for channel in cntrl.get_units("ControlUnit") + ) + + +class DuotecnoBinarySensor(DuotecnoEntity, BinarySensorEntity): + """Representation of a DuotecnoBinarySensor.""" + + _unit: ControlUnit + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._unit.is_on() diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py new file mode 100644 index 00000000000000..e7dfa53e53c0a3 --- /dev/null +++ b/homeassistant/components/duotecno/climate.py @@ -0,0 +1,92 @@ +"""Support for Duotecno climate devices.""" +from typing import Any, Final + +from duotecno.unit import SensUnit + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import DuotecnoEntity, api_call + +HVACMODE: Final = { + 0: HVACMode.OFF, + 1: HVACMode.HEAT, + 2: HVACMode.COOL, +} +HVACMODE_REVERSE: Final = {value: key for key, value in HVACMODE.items()} + +PRESETMODES: Final = { + "sun": 0, + "half_sun": 1, + "moon": 2, + "half_moon": 3, +} +PRESETMODES_REVERSE: Final = {value: key for key, value in PRESETMODES.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Duotecno climate based on config_entry.""" + cntrl = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + DuotecnoClimate(channel) for channel in cntrl.get_units(["SensUnit"]) + ) + + +class DuotecnoClimate(DuotecnoEntity, ClimateEntity): + """Representation of a Duotecno climate entity.""" + + _unit: SensUnit + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = list(HVACMODE_REVERSE) + _attr_preset_modes = list(PRESETMODES) + _attr_translation_key = "duotecno" + + @property + def current_temperature(self) -> int | None: + """Get the current temperature.""" + return self._unit.get_cur_temp() + + @property + def target_temperature(self) -> float | None: + """Get the target temperature.""" + return self._unit.get_target_temp() + + @property + def hvac_mode(self) -> HVACMode: + """Get the current hvac_mode.""" + return HVACMODE[self._unit.get_state()] + + @property + def preset_mode(self) -> str: + """Get the preset mode.""" + return PRESETMODES_REVERSE[self._unit.get_preset()] + + @api_call + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: + return + await self._unit.set_temp(temp) + + @api_call + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + await self._unit.set_preset(PRESETMODES[preset_mode]) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Duotecno does not support setting this, we can only display it.""" diff --git a/homeassistant/components/duotecno/const.py b/homeassistant/components/duotecno/const.py index 114867b8d95219..6bffe2358e173d 100644 --- a/homeassistant/components/duotecno/const.py +++ b/homeassistant/components/duotecno/const.py @@ -1,3 +1,4 @@ """Constants for the duotecno integration.""" +from typing import Final -DOMAIN = "duotecno" +DOMAIN: Final = "duotecno" diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index be2a74f884f42d..d04e883f867f67 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", - "requirements": ["pyDuotecno==2023.8.4"] + "requirements": ["pyDuotecno==2023.9.0"] } diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index 379291eb626330..a00647993a8b70 100644 --- a/homeassistant/components/duotecno/strings.json +++ b/homeassistant/components/duotecno/strings.json @@ -14,5 +14,21 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "climate": { + "duotecno": { + "state_attributes": { + "preset_mode": { + "state": { + "sun": "Sun", + "half_sun": "Half sun", + "moon": "Moon", + "half_moon": "Half moon" + } + } + } + } + } } } diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index ee0d2371a56666..f9f24128e2a046 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -16,6 +16,7 @@ RequiresEncryptionAPIError, UserService, UserServiceArgType, + VoiceAssistantAudioSettings, VoiceAssistantEventType, ) from awesomeversion import AwesomeVersion @@ -319,7 +320,10 @@ def _handle_pipeline_finished(self) -> None: self.voice_assistant_udp_server = None async def _handle_pipeline_start( - self, conversation_id: str, flags: int + self, + conversation_id: str, + flags: int, + audio_settings: VoiceAssistantAudioSettings, ) -> int | None: """Start a voice assistant pipeline.""" if self.voice_assistant_udp_server is not None: @@ -340,6 +344,7 @@ async def _handle_pipeline_start( device_id=self.device_id, conversation_id=conversation_id or None, flags=flags, + audio_settings=audio_settings, ), "esphome.voice_assistant_udp_server.run_pipeline", ) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b8bedce95567f2..d6fdd971fa6372 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==16.0.5", + "aioesphomeapi==17.0.0", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index c501d756e54084..baf3a9011e9d8c 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -7,18 +7,27 @@ import socket from typing import cast -from aioesphomeapi import VoiceAssistantCommandFlag, VoiceAssistantEventType +from aioesphomeapi import ( + VoiceAssistantAudioSettings, + VoiceAssistantCommandFlag, + VoiceAssistantEventType, +) from homeassistant.components import stt, tts from homeassistant.components.assist_pipeline import ( + AudioSettings, PipelineEvent, PipelineEventType, PipelineNotFound, PipelineStage, + WakeWordSettings, async_pipeline_from_audio_stream, select as pipeline_select, ) -from homeassistant.components.assist_pipeline.error import WakeWordDetectionError +from homeassistant.components.assist_pipeline.error import ( + WakeWordDetectionAborted, + WakeWordDetectionError, +) from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -64,7 +73,6 @@ def __init__( entry_data: RuntimeEntryData, handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], handle_finished: Callable[[], None], - audio_timeout: float = 2.0, ) -> None: """Initialize UDP receiver.""" self.context = Context() @@ -78,7 +86,6 @@ def __init__( self.handle_event = handle_event self.handle_finished = handle_finished self._tts_done = asyncio.Event() - self.audio_timeout = audio_timeout async def start_server(self) -> int: """Start accepting connections.""" @@ -212,9 +219,11 @@ async def run_pipeline( device_id: str, conversation_id: str | None, flags: int = 0, - pipeline_timeout: float = 30.0, + audio_settings: VoiceAssistantAudioSettings | None = None, ) -> None: """Run the Voice Assistant pipeline.""" + if audio_settings is None: + audio_settings = VoiceAssistantAudioSettings() tts_audio_output = ( "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" @@ -226,31 +235,36 @@ async def run_pipeline( else: start_stage = PipelineStage.STT try: - async with asyncio.timeout(pipeline_timeout): - await async_pipeline_from_audio_stream( - self.hass, - context=self.context, - event_callback=self._event_callback, - stt_metadata=stt.SpeechMetadata( - language="", # set in async_pipeline_from_audio_stream - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=self._iterate_packets(), - pipeline_id=pipeline_select.get_chosen_pipeline( - self.hass, DOMAIN, self.device_info.mac_address - ), - conversation_id=conversation_id, - device_id=device_id, - tts_audio_output=tts_audio_output, - start_stage=start_stage, - ) + await async_pipeline_from_audio_stream( + self.hass, + context=self.context, + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=self._iterate_packets(), + pipeline_id=pipeline_select.get_chosen_pipeline( + self.hass, DOMAIN, self.device_info.mac_address + ), + conversation_id=conversation_id, + device_id=device_id, + tts_audio_output=tts_audio_output, + start_stage=start_stage, + wake_word_settings=WakeWordSettings(timeout=5), + audio_settings=AudioSettings( + noise_suppression_level=audio_settings.noise_suppression_level, + auto_gain_dbfs=audio_settings.auto_gain, + volume_multiplier=audio_settings.volume_multiplier, + ), + ) - # Block until TTS is done sending - await self._tts_done.wait() + # Block until TTS is done sending + await self._tts_done.wait() _LOGGER.debug("Pipeline finished") except PipelineNotFound: @@ -262,6 +276,8 @@ async def run_pipeline( }, ) _LOGGER.warning("Pipeline not found") + except WakeWordDetectionAborted: + pass # Wake word detection was aborted and `handle_finished` is enough. except WakeWordDetectionError as e: self.handle_event( VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, @@ -270,19 +286,6 @@ async def run_pipeline( "message": e.message, }, ) - _LOGGER.warning("No Wake word provider found") - except asyncio.TimeoutError: - if self.stopped: - # The pipeline was stopped gracefully - return - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, - { - "code": "pipeline-timeout", - "message": "Pipeline timeout", - }, - ) - _LOGGER.warning("Pipeline timeout") finally: self.handle_finished() diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index 19f6965a4bb63d..bf2874712929f3 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -1,12 +1,14 @@ """API for fitbit bound to Home Assistant OAuth.""" import logging -from typing import Any +from typing import Any, cast from fitbit import Fitbit from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import METRIC_SYSTEM +from .const import FitbitUnitSystem from .model import FitbitDevice, FitbitProfile _LOGGER = logging.getLogger(__name__) @@ -19,31 +21,61 @@ def __init__( self, hass: HomeAssistant, client: Fitbit, + unit_system: FitbitUnitSystem | None = None, ) -> None: """Initialize Fitbit auth.""" self._hass = hass self._profile: FitbitProfile | None = None self._client = client + self._unit_system = unit_system @property def client(self) -> Fitbit: """Property to expose the underlying client library.""" return self._client - def get_user_profile(self) -> FitbitProfile: + async def async_get_user_profile(self) -> FitbitProfile: """Return the user profile from the API.""" - response: dict[str, Any] = self._client.user_profile_get() - _LOGGER.debug("user_profile_get=%s", response) - profile = response["user"] - return FitbitProfile( - encoded_id=profile["encodedId"], - full_name=profile["fullName"], - locale=profile.get("locale"), - ) + if self._profile is None: + response: dict[str, Any] = await self._hass.async_add_executor_job( + self._client.user_profile_get + ) + _LOGGER.debug("user_profile_get=%s", response) + profile = response["user"] + self._profile = FitbitProfile( + encoded_id=profile["encodedId"], + full_name=profile["fullName"], + locale=profile.get("locale"), + ) + return self._profile + + async def async_get_unit_system(self) -> FitbitUnitSystem: + """Get the unit system to use when fetching timeseries. + + This is used in a couple ways. The first is to determine the request + header to use when talking to the fitbit API which changes the + units returned by the API. The second is to tell Home Assistant the + units set in sensor values for the values returned by the API. + """ + if ( + self._unit_system is not None + and self._unit_system != FitbitUnitSystem.LEGACY_DEFAULT + ): + return self._unit_system + # Use units consistent with the account user profile or fallback to the + # home assistant unit settings. + profile = await self.async_get_user_profile() + if profile.locale == FitbitUnitSystem.EN_GB: + return FitbitUnitSystem.EN_GB + if self._hass.config.units is METRIC_SYSTEM: + return FitbitUnitSystem.METRIC + return FitbitUnitSystem.EN_US - def get_devices(self) -> list[FitbitDevice]: + async def async_get_devices(self) -> list[FitbitDevice]: """Return available devices.""" - devices: list[dict[str, str]] = self._client.get_devices() + devices: list[dict[str, str]] = await self._hass.async_add_executor_job( + self._client.get_devices + ) _LOGGER.debug("get_devices=%s", devices) return [ FitbitDevice( @@ -56,9 +88,18 @@ def get_devices(self) -> list[FitbitDevice]: for device in devices ] - def get_latest_time_series(self, resource_type: str) -> dict[str, Any]: + async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]: """Return the most recent value from the time series for the specified resource type.""" - response: dict[str, Any] = self._client.time_series(resource_type, period="7d") + + # Set request header based on the configured unit system + self._client.system = await self.async_get_unit_system() + + def _time_series() -> dict[str, Any]: + return cast( + dict[str, Any], self._client.time_series(resource_type, period="7d") + ) + + response: dict[str, Any] = await self._hass.async_add_executor_job(_time_series) _LOGGER.debug("time_series(%s)=%s", resource_type, response) key = resource_type.replace("/", "-") dated_results: list[dict[str, Any]] = response[key] diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 045b58cfc5e3a0..19734add07a1d1 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -1,16 +1,10 @@ """Constants for the Fitbit platform.""" from __future__ import annotations +from enum import StrEnum from typing import Final -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - UnitOfLength, - UnitOfMass, - UnitOfTime, - UnitOfVolume, -) +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET DOMAIN: Final = "fitbit" @@ -43,46 +37,31 @@ } DEFAULT_CLOCK_FORMAT: Final = "24H" - -FITBIT_MEASUREMENTS: Final[dict[str, dict[str, str]]] = { - "en_US": { - ATTR_DURATION: UnitOfTime.MILLISECONDS, - ATTR_DISTANCE: UnitOfLength.MILES, - ATTR_ELEVATION: UnitOfLength.FEET, - ATTR_HEIGHT: UnitOfLength.INCHES, - ATTR_WEIGHT: UnitOfMass.POUNDS, - ATTR_BODY: UnitOfLength.INCHES, - ATTR_LIQUIDS: UnitOfVolume.FLUID_OUNCES, - ATTR_BLOOD_GLUCOSE: f"{UnitOfMass.MILLIGRAMS}/dL", - ATTR_BATTERY: "", - }, - "en_GB": { - ATTR_DURATION: UnitOfTime.MILLISECONDS, - ATTR_DISTANCE: UnitOfLength.KILOMETERS, - ATTR_ELEVATION: UnitOfLength.METERS, - ATTR_HEIGHT: UnitOfLength.CENTIMETERS, - ATTR_WEIGHT: UnitOfMass.STONES, - ATTR_BODY: UnitOfLength.CENTIMETERS, - ATTR_LIQUIDS: UnitOfVolume.MILLILITERS, - ATTR_BLOOD_GLUCOSE: "mmol/L", - ATTR_BATTERY: "", - }, - "metric": { - ATTR_DURATION: UnitOfTime.MILLISECONDS, - ATTR_DISTANCE: UnitOfLength.KILOMETERS, - ATTR_ELEVATION: UnitOfLength.METERS, - ATTR_HEIGHT: UnitOfLength.CENTIMETERS, - ATTR_WEIGHT: UnitOfMass.KILOGRAMS, - ATTR_BODY: UnitOfLength.CENTIMETERS, - ATTR_LIQUIDS: UnitOfVolume.MILLILITERS, - ATTR_BLOOD_GLUCOSE: "mmol/L", - ATTR_BATTERY: "", - }, -} - BATTERY_LEVELS: Final[dict[str, int]] = { "High": 100, "Medium": 50, "Low": 20, "Empty": 0, } + + +class FitbitUnitSystem(StrEnum): + """Fitbit unit system set when sending requests to the Fitbit API. + + This is used as a header to tell the Fitbit API which type of units to return. + https://dev.fitbit.com/build/reference/web-api/developer-guide/application-design/#Units + + Prefer to leave unset for newer configurations to use the Home Assistant default units. + """ + + LEGACY_DEFAULT = "default" + """When set, will use an appropriate default using a legacy algorithm.""" + + METRIC = "metric" + """Use metric units.""" + + EN_US = "en_US" + """Use United States units.""" + + EN_GB = "en_GB" + """Use United Kingdom units.""" diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 3b1c831b1168f4..e08f56e0e3407e 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -1,6 +1,7 @@ """Support for the Fitbit API.""" from __future__ import annotations +import asyncio from collections.abc import Callable from dataclasses import dataclass import datetime @@ -29,6 +30,8 @@ CONF_CLIENT_SECRET, CONF_UNIT_SYSTEM, PERCENTAGE, + UnitOfLength, + UnitOfMass, UnitOfTime, ) from homeassistant.core import HomeAssistant @@ -39,7 +42,6 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import load_json_object -from homeassistant.util.unit_system import METRIC_SYSTEM from .api import FitbitApi from .const import ( @@ -56,9 +58,9 @@ FITBIT_AUTH_START, FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, - FITBIT_MEASUREMENTS, + FitbitUnitSystem, ) -from .model import FitbitDevice, FitbitProfile +from .model import FitbitDevice _LOGGER: Final = logging.getLogger(__name__) @@ -97,12 +99,36 @@ def _clock_format_12h(result: dict[str, Any]) -> str: return f"{hours}:{minutes:02d} {setting}" +def _weight_unit(unit_system: FitbitUnitSystem) -> UnitOfMass: + """Determine the weight unit.""" + if unit_system == FitbitUnitSystem.EN_US: + return UnitOfMass.POUNDS + if unit_system == FitbitUnitSystem.EN_GB: + return UnitOfMass.STONES + return UnitOfMass.KILOGRAMS + + +def _distance_unit(unit_system: FitbitUnitSystem) -> UnitOfLength: + """Determine the distance unit.""" + if unit_system == FitbitUnitSystem.EN_US: + return UnitOfLength.MILES + return UnitOfLength.KILOMETERS + + +def _elevation_unit(unit_system: FitbitUnitSystem) -> UnitOfLength: + """Determine the elevation unit.""" + if unit_system == FitbitUnitSystem.EN_US: + return UnitOfLength.FEET + return UnitOfLength.METERS + + @dataclass class FitbitSensorEntityDescription(SensorEntityDescription): """Describes Fitbit sensor entity.""" unit_type: str | None = None value_fn: Callable[[dict[str, Any]], Any] = _default_value_fn + unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( @@ -127,17 +153,17 @@ class FitbitSensorEntityDescription(SensorEntityDescription): FitbitSensorEntityDescription( key="activities/distance", name="Distance", - unit_type="distance", icon="mdi:map-marker", device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, + unit_fn=_distance_unit, ), FitbitSensorEntityDescription( key="activities/elevation", name="Elevation", - unit_type="elevation", icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, + unit_fn=_elevation_unit, ), FitbitSensorEntityDescription( key="activities/floors", @@ -201,17 +227,17 @@ class FitbitSensorEntityDescription(SensorEntityDescription): FitbitSensorEntityDescription( key="activities/tracker/distance", name="Tracker Distance", - unit_type="distance", icon="mdi:map-marker", device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, + unit_fn=_distance_unit, ), FitbitSensorEntityDescription( key="activities/tracker/elevation", name="Tracker Elevation", - unit_type="elevation", icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, + unit_fn=_elevation_unit, ), FitbitSensorEntityDescription( key="activities/tracker/floors", @@ -272,11 +298,11 @@ class FitbitSensorEntityDescription(SensorEntityDescription): FitbitSensorEntityDescription( key="body/weight", name="Weight", - unit_type="weight", icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.WEIGHT, value_fn=_body_value_fn, + unit_fn=_weight_unit, ), FitbitSensorEntityDescription( key="sleep/awakeningsCount", @@ -360,8 +386,13 @@ class FitbitSensorEntityDescription(SensorEntityDescription): vol.Optional(CONF_CLOCK_FORMAT, default=DEFAULT_CLOCK_FORMAT): vol.In( ["12H", "24H"] ), - vol.Optional(CONF_UNIT_SYSTEM, default="default"): vol.In( - ["en_GB", "en_US", "metric", "default"] + vol.Optional(CONF_UNIT_SYSTEM, default=FitbitUnitSystem.LEGACY_DEFAULT): vol.In( + [ + FitbitUnitSystem.EN_GB, + FitbitUnitSystem.EN_US, + FitbitUnitSystem.METRIC, + FitbitUnitSystem.LEGACY_DEFAULT, + ] ), } ) @@ -487,17 +518,13 @@ def setup_platform( if int(time.time()) - cast(int, expires_at) > 3600: authd_client.client.refresh_token() - api = FitbitApi(hass, authd_client) - user_profile = api.get_user_profile() - if (unit_system := config[CONF_UNIT_SYSTEM]) == "default": - authd_client.system = user_profile.locale - if authd_client.system != "en_GB": - if hass.config.units is METRIC_SYSTEM: - authd_client.system = "metric" - else: - authd_client.system = "en_US" - else: - authd_client.system = unit_system + api = FitbitApi(hass, authd_client, config[CONF_UNIT_SYSTEM]) + user_profile = asyncio.run_coroutine_threadsafe( + api.async_get_user_profile(), hass.loop + ).result() + unit_system = asyncio.run_coroutine_threadsafe( + api.async_get_unit_system(), hass.loop + ).result() clock_format = config[CONF_CLOCK_FORMAT] monitored_resources = config[CONF_MONITORED_RESOURCES] @@ -508,26 +535,26 @@ def setup_platform( entities = [ FitbitSensor( api, - user_profile, + user_profile.encoded_id, config_path, description, - hass.config.units is METRIC_SYSTEM, - clock_format, + units=description.unit_fn(unit_system), ) for description in resource_list if description.key in monitored_resources ] if "devices/battery" in monitored_resources: - devices = api.get_devices() + devices = asyncio.run_coroutine_threadsafe( + api.async_get_devices(), + hass.loop, + ).result() entities.extend( [ FitbitSensor( api, - user_profile, + user_profile.encoded_id, config_path, FITBIT_RESOURCE_BATTERY, - hass.config.units is METRIC_SYSTEM, - clock_format, device, ) for device in devices @@ -646,37 +673,25 @@ class FitbitSensor(SensorEntity): def __init__( self, api: FitbitApi, - user_profile: FitbitProfile, + user_profile_id: str, config_path: str, description: FitbitSensorEntityDescription, - is_metric: bool, - clock_format: str, device: FitbitDevice | None = None, + units: str | None = None, ) -> None: """Initialize the Fitbit sensor.""" self.entity_description = description self.api = api self.config_path = config_path - self.is_metric = is_metric - self.clock_format = clock_format self.device = device - self._attr_unique_id = f"{user_profile.encoded_id}_{description.key}" + self._attr_unique_id = f"{user_profile_id}_{description.key}" if device is not None: self._attr_name = f"{device.device_version} Battery" self._attr_unique_id = f"{self._attr_unique_id}_{device.id}" - if description.unit_type: - try: - measurement_system = FITBIT_MEASUREMENTS[self.api.client.system] - except KeyError: - if self.is_metric: - measurement_system = FITBIT_MEASUREMENTS["metric"] - else: - measurement_system = FITBIT_MEASUREMENTS["en_US"] - split_resource = description.key.rsplit("/", maxsplit=1)[-1] - unit_type = measurement_system[split_resource] - self._attr_native_unit_of_measurement = unit_type + if units is not None: + self._attr_native_unit_of_measurement = units @property def icon(self) -> str | None: @@ -701,21 +716,24 @@ def extra_state_attributes(self) -> dict[str, str | None]: return attrs - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data from the Fitbit API and update the states.""" resource_type = self.entity_description.key if resource_type == "devices/battery" and self.device is not None: device_id = self.device.id - registered_devs: list[FitbitDevice] = self.api.get_devices() + registered_devs: list[FitbitDevice] = await self.api.async_get_devices() self.device = next( device for device in registered_devs if device.id == device_id ) self._attr_native_value = self.device.battery else: - result = self.api.get_latest_time_series(resource_type) + result = await self.api.async_get_latest_time_series(resource_type) self._attr_native_value = self.entity_description.value_fn(result) + self.hass.async_add_executor_job(self._update_token) + + def _update_token(self) -> None: token = self.api.client.client.session.token config_contents = { ATTR_ACCESS_TOKEN: token.get("access_token"), diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index a8618b2df879b2..66078c50c1ae8b 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -25,8 +25,8 @@ ), SensorEntityDescription( key="ph", - translation_key="ph", icon="mdi:pool", + device_class=SensorDeviceClass.PH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json index 24557ff177b36b..235117afbd4d4d 100644 --- a/homeassistant/components/flipr/strings.json +++ b/homeassistant/components/flipr/strings.json @@ -40,9 +40,6 @@ "chlorine": { "name": "Chlorine" }, - "ph": { - "name": "pH" - }, "water_temperature": { "name": "Water temperature" }, diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 5be0ffb745d74f..00e5e57498fd76 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -100,7 +100,6 @@ def async_update_state(self) -> None: self._attr_is_on = self._device.last_known_valve_state == "open" self.async_write_ha_state() - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index 10a151dbcf6410..b5e0258d84406d 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -15,11 +15,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, FreeboxHomeCategory +from .home_base import FreeboxHomeEntity from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) + RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="raid_degraded", @@ -33,21 +35,105 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the binary sensors.""" + """Set up binary sensors.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] _LOGGER.debug("%s - %s - %s raid(s)", router.name, router.mac, len(router.raids)) - binary_entities = [ + binary_entities: list[BinarySensorEntity] = [ FreeboxRaidDegradedSensor(router, raid, description) for raid in router.raids.values() for description in RAID_SENSORS ] + for node in router.home_devices.values(): + if node["category"] == FreeboxHomeCategory.PIR: + binary_entities.append(FreeboxPirSensor(hass, router, node)) + elif node["category"] == FreeboxHomeCategory.DWS: + binary_entities.append(FreeboxDwsSensor(hass, router, node)) + + for endpoint in node["show_endpoints"]: + if ( + endpoint["name"] == "cover" + and endpoint["ep_type"] == "signal" + and endpoint.get("value") is not None + ): + binary_entities.append(FreeboxCoverSensor(hass, router, node)) + if binary_entities: async_add_entities(binary_entities, True) +class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity): + """Representation of a Freebox binary sensor.""" + + _sensor_name = "trigger" + + def __init__( + self, + hass: HomeAssistant, + router: FreeboxRouter, + node: dict[str, Any], + sub_node: dict[str, Any] | None = None, + ) -> None: + """Initialize a Freebox binary sensor.""" + super().__init__(hass, router, node, sub_node) + self._command_id = self.get_command_id( + node["type"]["endpoints"], "signal", self._sensor_name + ) + self._attr_is_on = self._edit_state(self.get_value("signal", self._sensor_name)) + + async def async_update_signal(self): + """Update name & state.""" + self._attr_is_on = self._edit_state( + await self.get_home_endpoint_value(self._command_id) + ) + await FreeboxHomeEntity.async_update_signal(self) + + def _edit_state(self, state: bool | None) -> bool | None: + """Edit state depending on sensor name.""" + if state is None: + return None + if self._sensor_name == "trigger": + return not state + return state + + +class FreeboxPirSensor(FreeboxHomeBinarySensor): + """Representation of a Freebox motion binary sensor.""" + + _attr_device_class = BinarySensorDeviceClass.MOTION + + +class FreeboxDwsSensor(FreeboxHomeBinarySensor): + """Representation of a Freebox door opener binary sensor.""" + + _attr_device_class = BinarySensorDeviceClass.DOOR + + +class FreeboxCoverSensor(FreeboxHomeBinarySensor): + """Representation of a cover Freebox plastic removal cover binary sensor (for some sensors: motion detector, door opener detector...).""" + + _attr_device_class = BinarySensorDeviceClass.SAFETY + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + _sensor_name = "cover" + + def __init__( + self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] + ) -> None: + """Initialize a cover for another device.""" + cover_node = next( + filter( + lambda x: (x["name"] == self._sensor_name and x["ep_type"] == "signal"), + node["type"]["endpoints"], + ), + None, + ) + super().__init__(hass, router, node, cover_node) + + class FreeboxRaidDegradedSensor(BinarySensorEntity): """Representation of a Freebox raid sensor.""" diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index fd11b949890d0c..f5c86ec0bce64d 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -80,27 +80,27 @@ def __init__( ) self._command_motion_detection = self.get_command_id( - node["type"]["endpoints"], ATTR_DETECTION + node["type"]["endpoints"], "slot", ATTR_DETECTION ) self._attr_extra_state_attributes = {} self.update_node(node) async def async_enable_motion_detection(self) -> None: """Enable motion detection in the camera.""" - await self.set_home_endpoint_value(self._command_motion_detection, True) - self._attr_motion_detection_enabled = True + if await self.set_home_endpoint_value(self._command_motion_detection, True): + self._attr_motion_detection_enabled = True async def async_disable_motion_detection(self) -> None: """Disable motion detection in camera.""" - await self.set_home_endpoint_value(self._command_motion_detection, False) - self._attr_motion_detection_enabled = False + if await self.set_home_endpoint_value(self._command_motion_detection, False): + self._attr_motion_detection_enabled = False async def async_update_signal(self) -> None: """Update the camera node.""" self.update_node(self._router.home_devices[self._id]) self.async_write_ha_state() - def update_node(self, node): + def update_node(self, node: dict[str, Any]) -> None: """Update params.""" self._name = node["label"].strip() diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 5bed7b3456aeba..0c3450d13b628d 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -86,6 +86,8 @@ class FreeboxHomeCategory(enum.StrEnum): HOME_COMPATIBLE_CATEGORIES = [ FreeboxHomeCategory.CAMERA, FreeboxHomeCategory.DWS, + FreeboxHomeCategory.IOHOME, FreeboxHomeCategory.KFB, FreeboxHomeCategory.PIR, + FreeboxHomeCategory.RTS, ] diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index d0bb8b103098cf..2cc1a5fcfe33ce 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -77,23 +77,36 @@ async def async_update_signal(self): ) self.async_write_ha_state() - async def set_home_endpoint_value(self, command_id: Any, value=None) -> None: + async def set_home_endpoint_value(self, command_id: Any, value=None) -> bool: """Set Home endpoint value.""" if command_id is None: _LOGGER.error("Unable to SET a value through the API. Command is None") - return + return False + await self._router.home.set_home_endpoint_value( self._id, command_id, {"value": value} ) + return True + + async def get_home_endpoint_value(self, command_id: Any) -> Any | None: + """Get Home endpoint value.""" + if command_id is None: + _LOGGER.error("Unable to GET a value through the API. Command is None") + return None - def get_command_id(self, nodes, name) -> int | None: + node = await self._router.home.get_home_endpoint_value(self._id, command_id) + return node.get("value") + + def get_command_id(self, nodes, ep_type, name) -> int | None: """Get the command id.""" node = next( - filter(lambda x: (x["name"] == name), nodes), + filter(lambda x: (x["name"] == name and x["ep_type"] == ep_type), nodes), None, ) if not node: - _LOGGER.warning("The Freebox Home device has no value for: %s", name) + _LOGGER.warning( + "The Freebox Home device has no command value for: %s/%s", name, ep_type + ) return None return node["id"] @@ -115,7 +128,7 @@ def remove_signal_update(self, dispacher: Any): """Register state update callback.""" self._remove_signal_update = dispacher - def get_value(self, ep_type, name): + def get_value(self, ep_type: str, name: str): """Get the value.""" node = next( filter( @@ -126,7 +139,7 @@ def get_value(self, ep_type, name): ) if not node: _LOGGER.warning( - "The Freebox Home device has no node for: %s/%s", ep_type, name + "The Freebox Home device has no node value for: %s/%s", ep_type, name ) return None return node.get("value") diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index cd5862a2f802be..6a73624a77674d 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -118,6 +118,7 @@ async def update_device_trackers(self) -> None: async def update_sensors(self) -> None: """Update Freebox sensors.""" + # System sensors syst_datas: dict[str, Any] = await self._api.system.get_config() @@ -145,7 +146,6 @@ async def update_sensors(self) -> None: self.call_list = await self._api.call.get_calls_log() await self._update_disks_sensors() - await self._update_raids_sensors() async_dispatcher_send(self.hass, self.signal_sensor_update) @@ -165,6 +165,7 @@ async def _update_disks_sensors(self) -> None: async def _update_raids_sensors(self) -> None: """Update Freebox raids.""" + # None at first request if not self.supports_raid: return diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index d199d2c5a2c219..8cb41ebcbe157d 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -18,7 +18,7 @@ ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -103,6 +103,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> bool: + """Remove Fritzbox config entry from a device.""" + coordinator: FritzboxDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + CONF_COORDINATOR + ] + + for identifier in device.identifiers: + if identifier[0] == DOMAIN and ( + identifier[1] in coordinator.data.devices + or identifier[1] in coordinator.data.templates + ): + return False + + return True + + class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC): """Basis FritzBox entity.""" diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 59315e9f5763c1..a5a4d76f9e7a03 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -156,9 +156,18 @@ def update_key(self, key: str, val: str) -> None: "src": f"/static/icons/favicon-{size}x{size}.png", "sizes": f"{size}x{size}", "type": "image/png", - "purpose": "maskable any", + "purpose": "any", } for size in (192, 384, 512, 1024) + ] + + [ + { + "src": f"/static/icons/maskable_icon-{size}x{size}.png", + "sizes": f"{size}x{size}", + "type": "image/png", + "purpose": "maskable", + } + for size in (48, 72, 96, 128, 192, 384, 512) ], "screenshots": [ { @@ -171,6 +180,7 @@ def update_key(self, key: str, val: str) -> None: "name": "Home Assistant", "short_name": "Assistant", "start_url": "/?homescreen=1", + "id": "/?homescreen=1", "theme_color": DEFAULT_THEME_COLOR, "prefer_related_applications": True, "related_applications": [ diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6291e3a237e9c4..9f01fadb710c47 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230911.0"] + "requirements": ["home-assistant-frontend==20230928.0"] } diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index 58b81bc088e310..72555b629d7ccd 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -1,12 +1,16 @@ """Config flow for Glances.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any -from glances_api.exceptions import GlancesApiError +from glances_api.exceptions import ( + GlancesApiAuthorizationError, + GlancesApiConnectionError, +) import voluptuous as vol -from homeassistant import config_entries, exceptions +from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -15,7 +19,6 @@ CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from . import get_api @@ -41,19 +44,53 @@ ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: - """Validate the user input allows us to connect.""" - api = get_api(hass, data) - try: - await api.get_ha_sensor_data() - except GlancesApiError as err: - raise CannotConnect from err - - class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Glances config flow.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + errors = {} + assert self._reauth_entry + if user_input is not None: + user_input = {**self._reauth_entry.data, **user_input} + api = get_api(self.hass, user_input) + try: + await api.get_ha_sensor_data() + except GlancesApiAuthorizationError: + errors["base"] = "invalid_auth" + except GlancesApiConnectionError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + description_placeholders={ + CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME] + }, + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -64,19 +101,19 @@ async def async_step_user( self._async_abort_entries_match( {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} ) + api = get_api(self.hass, user_input) try: - await validate_input(self.hass, user_input) + await api.get_ha_sensor_data() + except GlancesApiAuthorizationError: + errors["base"] = "invalid_auth" + except GlancesApiConnectionError: + errors["base"] = "cannot_connect" + else: return self.async_create_entry( title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", data=user_input, ) - except CannotConnect: - errors["base"] = "cannot_connect" return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 8d2bd0daaa3fac..9fa9346b95f300 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -7,6 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -36,6 +37,8 @@ async def _async_update_data(self) -> dict[str, Any]: """Get the latest data from the Glances REST API.""" try: data = await self.api.get_ha_sensor_data() + except exceptions.GlancesApiAuthorizationError as err: + raise ConfigEntryAuthFailed from err except exceptions.GlancesApiError as err: raise UpdateFailed from err return data or {} diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index b46716b43c0a27..fdd0c44b31ba9b 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -11,13 +11,21 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } + }, + "reauth_confirm": { + "description": "The password for {username} is invalid.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "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%]" } } } diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 6ec8ca5d747fba..060f7ce50e5984 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -6,6 +6,7 @@ camera, climate, cover, + event, fan, group, humidifier, @@ -48,6 +49,7 @@ "binary_sensor", "climate", "cover", + "event", "fan", "group", "humidifier", @@ -73,6 +75,7 @@ TYPE_CURTAIN = f"{PREFIX_TYPES}CURTAIN" TYPE_DEHUMIDIFIER = f"{PREFIX_TYPES}DEHUMIDIFIER" TYPE_DOOR = f"{PREFIX_TYPES}DOOR" +TYPE_DOORBELL = f"{PREFIX_TYPES}DOORBELL" TYPE_FAN = f"{PREFIX_TYPES}FAN" TYPE_GARAGE = f"{PREFIX_TYPES}GARAGE" TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER" @@ -162,6 +165,7 @@ (cover.DOMAIN, cover.CoverDeviceClass.GATE): TYPE_GARAGE, (cover.DOMAIN, cover.CoverDeviceClass.SHUTTER): TYPE_SHUTTER, (cover.DOMAIN, cover.CoverDeviceClass.WINDOW): TYPE_WINDOW, + (event.DOMAIN, event.EventDeviceClass.DOORBELL): TYPE_DOORBELL, ( humidifier.DOMAIN, humidifier.HumidifierDeviceClass.DEHUMIDIFIER, diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index c1b505b2bd49c2..ee8e5872348d05 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -9,6 +9,7 @@ from http import HTTPStatus import logging import pprint +from typing import Any from aiohttp.web import json_response from awesomeversion import AwesomeVersion @@ -183,7 +184,9 @@ def should_2fa(self, state): """If an entity should have 2FA checked.""" return True - async def async_report_state(self, message, agent_user_id: str): + async def async_report_state( + self, message: dict[str, Any], agent_user_id: str, event_id: str | None = None + ) -> HTTPStatus | None: """Send a state report to Google.""" raise NotImplementedError @@ -234,6 +237,33 @@ async def async_sync_entities_all(self) -> int: ) return max(res, default=204) + async def async_sync_notification( + self, agent_user_id: str, event_id: str, payload: dict[str, Any] + ) -> HTTPStatus: + """Sync notification to Google.""" + # Remove any pending sync + self._google_sync_unsub.pop(agent_user_id, lambda: None)() + status = await self.async_report_state(payload, agent_user_id, event_id) + assert status is not None + if status == HTTPStatus.NOT_FOUND: + await self.async_disconnect_agent_user(agent_user_id) + return status + + async def async_sync_notification_all( + self, event_id: str, payload: dict[str, Any] + ) -> HTTPStatus: + """Sync notification to Google for all registered agents.""" + if not self._store.agent_user_ids: + return HTTPStatus.NO_CONTENT + + res = await gather( + *( + self.async_sync_notification(agent_user_id, event_id, payload) + for agent_user_id in self._store.agent_user_ids + ) + ) + return max(res, default=HTTPStatus.NO_CONTENT) + @callback def async_schedule_google_sync(self, agent_user_id: str): """Schedule a sync.""" @@ -617,7 +647,6 @@ def sync_serialize(self, agent_user_id, instance_uuid): state.domain, state.attributes.get(ATTR_DEVICE_CLASS) ), } - # Add aliases if (config_aliases := entity_config.get(CONF_ALIASES, [])) or ( entity_entry and entity_entry.aliases @@ -639,6 +668,10 @@ def sync_serialize(self, agent_user_id, instance_uuid): for trt in traits: device["attributes"].update(trt.sync_attributes()) + # Add trait options + for trt in traits: + device.update(trt.sync_options()) + # Add roomhint if room := entity_config.get(CONF_ROOM_HINT): device["roomHint"] = room @@ -681,6 +714,16 @@ def query_serialize(self): return attrs + @callback + def notifications_serialize(self) -> dict[str, Any] | None: + """Serialize the payload for notifications to be sent.""" + notifications: dict[str, Any] = {} + + for trt in self.traits(): + deep_update(notifications, trt.query_notifications() or {}) + + return notifications or None + @callback def reachable_device_serialize(self): """Serialize entity for a REACHABLE_DEVICE response.""" diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 84d5e4a3364b34..c0e4f715c16946 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -158,7 +158,7 @@ def should_2fa(self, state): """If an entity should have 2FA checked.""" return True - async def _async_request_sync_devices(self, agent_user_id: str): + async def _async_request_sync_devices(self, agent_user_id: str) -> HTTPStatus: if CONF_SERVICE_ACCOUNT in self._config: return await self.async_call_homegraph_api( REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id} @@ -220,14 +220,18 @@ async def _call(): _LOGGER.error("Could not contact %s", url) return HTTPStatus.INTERNAL_SERVER_ERROR - async def async_report_state(self, message, agent_user_id: str): + async def async_report_state( + self, message: dict[str, Any], agent_user_id: str, event_id: str | None = None + ) -> HTTPStatus: """Send a state report to Google.""" data = { "requestId": uuid4().hex, "agentUserId": agent_user_id, "payload": message, } - await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) + if event_id is not None: + data["eventId"] = event_id + return await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) class GoogleAssistantView(HomeAssistantView): diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 52228bb8715d4f..87af12ad0fc670 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -4,6 +4,7 @@ from collections import deque import logging from typing import Any +from uuid import uuid4 from homeassistant.const import MATCH_ALL from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback @@ -30,7 +31,7 @@ @callback def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig): - """Enable state reporting.""" + """Enable state and notification reporting.""" checker = None unsub_pending: CALLBACK_TYPE | None = None pending: deque[dict[str, Any]] = deque([{}]) @@ -79,6 +80,23 @@ async def async_entity_state_listener( ): return + if (notifications := entity.notifications_serialize()) is not None: + event_id = uuid4().hex + payload = { + "devices": {"notifications": {entity.state.entity_id: notifications}} + } + _LOGGER.info( + "Sending event notification for entity %s", + entity.state.entity_id, + ) + result = await google_config.async_sync_notification_all(event_id, payload) + if result != 200: + _LOGGER.error( + "Unable to send notification with result code: %s, check log for more" + " info", + result, + ) + try: entity_data = entity.query_serialize() except SmartHomeError as err: diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 425a394b522884..a39dfd3f3dcc65 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2,6 +2,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from datetime import datetime, timedelta import logging from typing import Any, TypeVar @@ -12,6 +13,7 @@ camera, climate, cover, + event, fan, group, humidifier, @@ -74,9 +76,10 @@ STATE_UNKNOWN, UnitOfTemperature, ) -from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.helpers.network import get_url from homeassistant.util import color as color_util, dt as dt_util +from homeassistant.util.dt import utcnow from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -115,6 +118,7 @@ TRAIT_FANSPEED = f"{PREFIX_TRAITS}FanSpeed" TRAIT_MODES = f"{PREFIX_TRAITS}Modes" TRAIT_INPUTSELECTOR = f"{PREFIX_TRAITS}InputSelector" +TRAIT_OBJECTDETECTION = f"{PREFIX_TRAITS}ObjectDetection" TRAIT_OPENCLOSE = f"{PREFIX_TRAITS}OpenClose" TRAIT_VOLUME = f"{PREFIX_TRAITS}Volume" TRAIT_ARMDISARM = f"{PREFIX_TRAITS}ArmDisarm" @@ -221,7 +225,7 @@ def might_2fa(domain, features, device_class): def supported(domain, features, device_class, attributes): """Test if state is supported.""" - def __init__(self, hass, state, config): + def __init__(self, hass: HomeAssistant, state, config) -> None: """Initialize a trait for a state.""" self.hass = hass self.state = state @@ -231,10 +235,17 @@ def sync_attributes(self): """Return attributes for a sync request.""" raise NotImplementedError + def sync_options(self) -> dict[str, Any]: + """Add options for the sync request.""" + return {} + def query_attributes(self): """Return the attributes of this trait for this entity.""" raise NotImplementedError + def query_notifications(self) -> dict[str, Any] | None: + """Return notifications payload.""" + def can_execute(self, command, params): """Test if command can be executed.""" return command in self.commands @@ -335,6 +346,60 @@ async def execute(self, command, data, params, challenge): } +@register_trait +class ObjectDetection(_Trait): + """Trait to object detection. + + https://developers.google.com/actions/smarthome/traits/objectdetection + """ + + name = TRAIT_OBJECTDETECTION + commands = [] + + @staticmethod + def supported(domain, features, device_class, _) -> bool: + """Test if state is supported.""" + return ( + domain == event.DOMAIN and device_class == event.EventDeviceClass.DOORBELL + ) + + def sync_attributes(self): + """Return ObjectDetection attributes for a sync request.""" + return {} + + def sync_options(self) -> dict[str, Any]: + """Add options for the sync request.""" + return {"notificationSupportedByAgent": True} + + def query_attributes(self): + """Return ObjectDetection query attributes.""" + return {} + + def query_notifications(self) -> dict[str, Any] | None: + """Return notifications payload.""" + + if self.state.state in {STATE_UNKNOWN, STATE_UNAVAILABLE}: + return None + + # Only notify if last event was less then 30 seconds ago + time_stamp = datetime.fromisoformat(self.state.state) + if (utcnow() - time_stamp) > timedelta(seconds=30): + return None + + return { + "ObjectDetection": { + "objects": { + "unclassified": 1, + }, + "priority": 0, + "detectionTimestamp": int(time_stamp.timestamp() * 1000), + }, + } + + async def execute(self, command, data, params, challenge): + """Execute an ObjectDetection command.""" + + @register_trait class OnOffTrait(_Trait): """Trait to offer basic on and off functionality. diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 2ee12f0154cdf6..be776df1751688 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -112,12 +112,22 @@ def _update_info(self, now=None): last_seen = dt_util.as_utc(person.datetime) if last_seen < self._prev_seen.get(dev_id, last_seen): - _LOGGER.warning( + _LOGGER.debug( "Ignoring %s update because timestamp is older than last timestamp", person.nickname, ) _LOGGER.debug("%s < %s", last_seen, self._prev_seen[dev_id]) continue + if last_seen == self._prev_seen.get(dev_id, last_seen) and hasattr( + self, "success_init" + ): + _LOGGER.debug( + "Ignoring %s update because timestamp " + "is the same as the last timestamp %s", + person.nickname, + last_seen, + ) + continue self._prev_seen[dev_id] = last_seen attrs = { diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 270309149ef3ce..75b2535bd44602 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -49,7 +49,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.loader import bind_hass -from homeassistant.util.dt import utcnow +from homeassistant.util.dt import now from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401 from .addon_panel import async_setup_addon_panel @@ -88,11 +88,13 @@ async_get_addon_discovery_info, async_get_addon_info, async_get_addon_store_info, + async_get_green_settings, async_get_yellow_settings, async_install_addon, async_reboot_host, async_restart_addon, async_set_addon_options, + async_set_green_settings, async_set_yellow_settings, async_start_addon, async_stop_addon, @@ -177,7 +179,7 @@ def valid_addon(value: Any) -> str: SCHEMA_BACKUP_FULL = vol.Schema( { vol.Optional( - ATTR_NAME, default=lambda: utcnow().strftime("%Y-%m-%d %H:%M:%S") + ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S") ): cv.string, vol.Optional(ATTR_PASSWORD): cv.string, vol.Optional(ATTR_COMPRESSED): cv.boolean, diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 020a4365ec664d..fe9e1ba1d2ef52 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -263,6 +263,27 @@ async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> b return await hassio.send_command(command, timeout=None) +@api_data +async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]: + """Return settings specific to Home Assistant Green.""" + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command("/os/boards/green", method="get") + + +@api_data +async def async_set_green_settings( + hass: HomeAssistant, settings: dict[str, bool] +) -> dict: + """Set settings specific to Home Assistant Green. + + Returns an empty dict. + """ + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command( + "/os/boards/green", method="post", payload=settings + ) + + @api_data async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]: """Return settings specific to Home Assistant Yellow.""" diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 2b5fd3fc6860cb..892e577490dd52 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -60,3 +60,5 @@ reload_config_entry: text: save_persistent_states: + +reload_all: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 53510a94f01953..a3435a8d1f5f2c 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -125,6 +125,10 @@ "save_persistent_states": { "name": "Save persistent states", "description": "Saves the persistent states immediately. Maintains the normal periodic saving interval." + }, + "reload_all": { + "name": "Reload all", + "description": "Reload all YAML configuration that can be reloaded without restarting Home Assistant." } } } diff --git a/homeassistant/components/homeassistant_green/config_flow.py b/homeassistant/components/homeassistant_green/config_flow.py index 17ba9aacbc5448..c3491de430e36f 100644 --- a/homeassistant/components/homeassistant_green/config_flow.py +++ b/homeassistant/components/homeassistant_green/config_flow.py @@ -1,22 +1,100 @@ """Config flow for the Home Assistant Green integration.""" from __future__ import annotations +import asyncio +import logging from typing import Any -from homeassistant.config_entries import ConfigFlow +import aiohttp +import voluptuous as vol + +from homeassistant.components.hassio import ( + HassioAPIError, + async_get_green_settings, + async_set_green_settings, + is_hassio, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + +STEP_HW_SETTINGS_SCHEMA = vol.Schema( + { + # Sorted to match front panel left to right + vol.Required("power_led"): selector.BooleanSelector(), + vol.Required("activity_led"): selector.BooleanSelector(), + vol.Required("system_health_led"): selector.BooleanSelector(), + } +) + class HomeAssistantGreenConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Home Assistant Green.""" VERSION = 1 + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> HomeAssistantGreenOptionsFlow: + """Return the options flow.""" + return HomeAssistantGreenOptionsFlow() + async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle the initial step.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") return self.async_create_entry(title="Home Assistant Green", data={}) + + +class HomeAssistantGreenOptionsFlow(OptionsFlow): + """Handle an option flow for Home Assistant Green.""" + + _hw_settings: dict[str, bool] | None = None + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if not is_hassio(self.hass): + return self.async_abort(reason="not_hassio") + + return await self.async_step_hardware_settings() + + async def async_step_hardware_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle hardware settings.""" + + if user_input is not None: + if self._hw_settings == user_input: + return self.async_create_entry(data={}) + try: + async with asyncio.timeout(10): + await async_set_green_settings(self.hass, user_input) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to write hardware settings", exc_info=err) + return self.async_abort(reason="write_hw_settings_error") + return self.async_create_entry(data={}) + + try: + async with asyncio.timeout(10): + self._hw_settings: dict[str, bool] = await async_get_green_settings( + self.hass + ) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to read hardware settings", exc_info=err) + return self.async_abort(reason="read_hw_settings_error") + + schema = self.add_suggested_values_to_schema( + STEP_HW_SETTINGS_SCHEMA, self._hw_settings + ) + + return self.async_show_form(step_id="hardware_settings", data_schema=schema) diff --git a/homeassistant/components/homeassistant_green/strings.json b/homeassistant/components/homeassistant_green/strings.json new file mode 100644 index 00000000000000..9066ca64e5c95b --- /dev/null +++ b/homeassistant/components/homeassistant_green/strings.json @@ -0,0 +1,28 @@ +{ + "options": { + "step": { + "hardware_settings": { + "title": "Configure hardware settings", + "data": { + "activity_led": "Green: activity LED", + "power_led": "White: power LED", + "system_health_led": "Yellow: system health LED" + } + }, + "reboot_menu": { + "title": "Reboot required", + "description": "The settings have changed, but the new settings will not take effect until the system is rebooted", + "menu_options": { + "reboot_later": "Reboot manually later", + "reboot_now": "Reboot now" + } + } + }, + "abort": { + "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "read_hw_settings_error": "Failed to read hardware settings", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "write_hw_settings_error": "Failed to write hardware settings" + } + } +} diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index b4723a887424fe..c04575d80052c6 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -8,7 +8,6 @@ import logging from typing import Any, Protocol -import async_timeout import voluptuous as vol import yarl @@ -74,7 +73,7 @@ class WaitingAddonManager(AddonManager): async def async_wait_until_addon_state(self, *states: AddonState) -> None: """Poll an addon's info until it is in a specific state.""" - async with async_timeout.timeout(ADDON_INFO_POLL_TIMEOUT): + async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT): while True: try: info = await self.async_get_addon_info() diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 514c218b1010b8..bb4efb7db6cb9c 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -856,8 +856,7 @@ def _async_register_bridge(self) -> None: connection = (dr.CONNECTION_NETWORK_MAC, formatted_mac) identifier = (DOMAIN, self._entry_id, BRIDGE_SERIAL_NUMBER) self._async_purge_old_bridges(dev_reg, identifier, connection) - is_accessory_mode = self._homekit_mode == HOMEKIT_MODE_ACCESSORY - hk_mode_name = "Accessory" if is_accessory_mode else "Bridge" + accessory_type = type(self.driver.accessory).__name__ dev_reg.async_get_or_create( config_entry_id=self._entry_id, identifiers={ @@ -866,7 +865,7 @@ def _async_register_bridge(self) -> None: connections={connection}, manufacturer=MANUFACTURER, name=accessory_friendly_name(self._entry_title, self.driver.accessory), - model=f"HomeKit {hk_mode_name}", + model=accessory_type, entry_type=dr.DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index f88047795ca59b..5a1e9bc1ea229a 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -183,7 +183,9 @@ def get_accessory( # noqa: C901 device_class = state.attributes.get(ATTR_DEVICE_CLASS) feature_list = config.get(CONF_FEATURE_LIST, []) - if device_class == MediaPlayerDeviceClass.TV: + if device_class == MediaPlayerDeviceClass.RECEIVER: + a_type = "ReceiverMediaPlayer" + elif device_class == MediaPlayerDeviceClass.TV: a_type = "TelevisionMediaPlayer" elif validate_media_player_features(state, feature_list): a_type = "MediaPlayer" @@ -274,7 +276,7 @@ def __init__( aid: int, config: dict, *args: Any, - category: str = CATEGORY_OTHER, + category: int = CATEGORY_OTHER, device_id: str | None = None, **kwargs: Any, ) -> None: @@ -463,7 +465,9 @@ def async_update_event_state_callback( def async_update_state_callback(self, new_state: State | None) -> None: """Handle state change listener callback.""" _LOGGER.debug("New_state: %s", new_state) - if new_state is None: + # HomeKit handles unavailable state via the available property + # so we should not propagate it here + if new_state is None or new_state.state == STATE_UNAVAILABLE: return battery_state = None battery_charging_state = None diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 81dbf4f7e2e00a..bb5ae1ffd1ce5e 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -115,6 +115,9 @@ TYPE_SWITCH = "switch" TYPE_VALVE = "valve" +# #### Categories #### +CATEGORY_RECEIVER = 34 + # #### Services #### SERV_ACCESSORY_INFO = "AccessoryInformation" SERV_AIR_QUALITY_SENSOR = "AirQualitySensor" diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index f57536263cafea..30ecfba569e7a8 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -11,7 +11,7 @@ "include_exclude_mode": "Inclusion Mode", "domains": "[%key:component::homekit::config::step::user::data::include_domains%]" }, - "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", + "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", "title": "Select mode and domains." }, "accessory": { @@ -57,7 +57,7 @@ "data": { "include_domains": "Domains to include" }, - "description": "Choose the domains to be included. All supported entities in the domain will be included except for categorized entities. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.", + "description": "Choose the domains to be included. All supported entities in the domain will be included except for categorized entities. A separate HomeKit instance in accessory mode will be created for each tv/receiver media player, activity based remote, lock, and camera.", "title": "Select domains to be included" }, "pairing": { diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index eae7ed2742a87c..da7fdceede31e5 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -1,5 +1,6 @@ """Class to hold all media player accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_SWITCH @@ -36,6 +37,7 @@ from .accessories import TYPES, HomeAccessory from .const import ( ATTR_KEY_NAME, + CATEGORY_RECEIVER, CHAR_ACTIVE, CHAR_MUTE, CHAR_NAME, @@ -218,18 +220,20 @@ def async_update_state(self, new_state): class TelevisionMediaPlayer(RemoteInputSelectAccessory): """Generate a Television Media Player accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a Television Media Player accessory object.""" super().__init__( MediaPlayerEntityFeature.SELECT_SOURCE, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, *args, + **kwargs, ) state = self.hass.states.get(self.entity_id) + assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - self.chars_speaker = [] + self.chars_speaker: list[str] = [] self._supports_play_pause = features & ( MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE @@ -358,3 +362,17 @@ def async_update_state(self, new_state): self.char_mute.set_value(current_mute_state) self._async_update_input_state(hk_state, new_state) + + +@TYPES.register("ReceiverMediaPlayer") +class ReceiverMediaPlayer(TelevisionMediaPlayer): + """Generate a Receiver Media Player accessory. + + For HomeKit, a Receiver Media Player is exactly the same as a + Television Media Player except it has a different category + which will tell HomeKit how to render the device. + """ + + def __init__(self, *args: Any) -> None: + """Initialize a Receiver Media Player accessory object.""" + super().__init__(*args, category=CATEGORY_RECEIVER) diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 69441b5ebe186f..e440a5b3ac0bed 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -1,6 +1,7 @@ """Class to hold remote accessories.""" from abc import ABC, abstractmethod import logging +from typing import Any from pyhap.const import CATEGORY_TELEVISION @@ -80,19 +81,21 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): def __init__( self, - required_feature, - source_key, - source_list_key, - *args, - **kwargs, - ): + required_feature: int, + source_key: str, + source_list_key: str, + *args: Any, + category: int = CATEGORY_TELEVISION, + **kwargs: Any, + ) -> None: """Initialize a InputSelect accessory object.""" - super().__init__(*args, category=CATEGORY_TELEVISION, **kwargs) + super().__init__(*args, category=category, **kwargs) state = self.hass.states.get(self.entity_id) + assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - self._mapped_sources_list = [] - self._mapped_sources = {} + self._mapped_sources_list: list[str] = [] + self._mapped_sources: dict[str, str] = {} self.source_key = source_key self.source_list_key = source_list_key self.sources = [] diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 8287c2b7845b58..151b97f2cdaf15 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -614,7 +614,8 @@ def state_needs_accessory_mode(state: State) -> bool: return ( state.domain == MEDIA_PLAYER_DOMAIN - and state.attributes.get(ATTR_DEVICE_CLASS) == MediaPlayerDeviceClass.TV + and state.attributes.get(ATTR_DEVICE_CLASS) + in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER) or state.domain == REMOTE_DOMAIN and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & RemoteEntityFeature.ACTIVITY diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index cde9aa732c3f92..f60dc66996842e 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -77,6 +77,9 @@ CharacteristicsTypes.VENDOR_EVE_ENERGY_WATT: "sensor", CharacteristicsTypes.VENDOR_EVE_DEGREE_AIR_PRESSURE: "sensor", CharacteristicsTypes.VENDOR_EVE_DEGREE_ELEVATION: "number", + CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION: "number", + CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY: "number", + CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: "sensor", CharacteristicsTypes.VENDOR_HAA_SETUP: "button", CharacteristicsTypes.VENDOR_HAA_UPDATE: "button", CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY: "sensor", @@ -101,6 +104,7 @@ CharacteristicsTypes.MUTE: "switch", CharacteristicsTypes.FILTER_LIFE_LEVEL: "sensor", CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE: "switch", + CharacteristicsTypes.TEMPERATURE_UNITS: "select", } STARTUP_EXCEPTIONS = ( diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 73eb699007c7fe..0f4af988c14b00 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -299,8 +299,14 @@ def extra_state_attributes(self) -> dict[str, Any]: return {"obstruction-detected": obstruction_detected} +class HomeKitWindow(HomeKitWindowCover): + """Representation of a HomeKit Window.""" + + _attr_device_class = CoverDeviceClass.WINDOW + + ENTITY_TYPES = { ServicesTypes.GARAGE_DOOR_OPENER: HomeKitGarageDoorCover, ServicesTypes.WINDOW_COVERING: HomeKitWindowCover, - ServicesTypes.WINDOW: HomeKitWindowCover, + ServicesTypes.WINDOW: HomeKitWindow, } diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index c99142da475208..5687cd4dba3cca 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.3"], + "requirements": ["aiohomekit==3.0.5"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index b44aed161432ab..c453efb821905c 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -49,6 +49,18 @@ icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, ), + CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION: NumberEntityDescription( + key=CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION, + name="Duration", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + ), + CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY: NumberEntityDescription( + key=CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY, + name="Sensitivity", + icon="mdi:knob", + entity_category=EntityCategory.CONFIG, + ), } diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index 76067aea0614e4..09bb57923c66e8 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -1,18 +1,54 @@ """Support for Homekit select entities.""" from __future__ import annotations +from dataclasses import dataclass +from enum import IntEnum + from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes +from aiohomekit.model.characteristics.const import TemperatureDisplayUnits -from homeassistant.components.select import SelectEntity +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES from .connection import HKDevice from .entity import CharacteristicEntity + +@dataclass +class HomeKitSelectEntityDescriptionRequired: + """Required fields for HomeKitSelectEntityDescription.""" + + choices: dict[str, IntEnum] + + +@dataclass +class HomeKitSelectEntityDescription( + SelectEntityDescription, HomeKitSelectEntityDescriptionRequired +): + """A generic description of a select entity backed by a single characteristic.""" + + name: str | None = None + + +SELECT_ENTITIES: dict[str, HomeKitSelectEntityDescription] = { + CharacteristicsTypes.TEMPERATURE_UNITS: HomeKitSelectEntityDescription( + key="temperature_display_units", + translation_key="temperature_display_units", + name="Temperature Display Units", + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + choices={ + "celsius": TemperatureDisplayUnits.CELSIUS, + "fahrenheit": TemperatureDisplayUnits.FAHRENHEIT, + }, + ), +} + _ECOBEE_MODE_TO_TEXT = { 0: "home", 1: "sleep", @@ -21,7 +57,58 @@ _ECOBEE_MODE_TO_NUMBERS = {v: k for (k, v) in _ECOBEE_MODE_TO_TEXT.items()} -class EcobeeModeSelect(CharacteristicEntity, SelectEntity): +class BaseHomeKitSelect(CharacteristicEntity, SelectEntity): + """Base entity for select entities backed by a single characteristics.""" + + +class HomeKitSelect(BaseHomeKitSelect): + """Representation of a select control on a homekit accessory.""" + + entity_description: HomeKitSelectEntityDescription + + def __init__( + self, + conn: HKDevice, + info: ConfigType, + char: Characteristic, + description: HomeKitSelectEntityDescription, + ) -> None: + """Initialise a HomeKit select control.""" + self.entity_description = description + + self._choice_to_enum = self.entity_description.choices + self._enum_to_choice = { + v: k for (k, v) in self.entity_description.choices.items() + } + + self._attr_options = list(self.entity_description.choices.keys()) + + super().__init__(conn, info, char) + + def get_characteristic_types(self) -> list[str]: + """Define the homekit characteristics the entity cares about.""" + return [self._char.type] + + @property + def name(self) -> str | None: + """Return the name of the device if any.""" + if name := self.accessory.name: + return f"{name} {self.entity_description.name}" + return self.entity_description.name + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self._enum_to_choice.get(self._char.value) + + async def async_select_option(self, option: str) -> None: + """Set the current option.""" + await self.async_put_characteristics( + {self._char.type: self._choice_to_enum[option]} + ) + + +class EcobeeModeSelect(BaseHomeKitSelect): """Represents a ecobee mode select entity.""" _attr_options = ["home", "sleep", "away"] @@ -64,14 +151,23 @@ async def async_setup_entry( @callback def async_add_characteristic(char: Characteristic) -> bool: - if char.type == CharacteristicsTypes.VENDOR_ECOBEE_CURRENT_MODE: - info = {"aid": char.service.accessory.aid, "iid": char.service.iid} - entity = EcobeeModeSelect(conn, info, char) + entities: list[BaseHomeKitSelect] = [] + info = {"aid": char.service.accessory.aid, "iid": char.service.iid} + + if description := SELECT_ENTITIES.get(char.type): + entities.append(HomeKitSelect(conn, info, char, description)) + elif char.type == CharacteristicsTypes.VENDOR_ECOBEE_CURRENT_MODE: + entities.append(EcobeeModeSelect(conn, info, char)) + + if not entities: + return False + + for entity in entities: conn.async_migrate_unique_id( entity.old_unique_id, entity.unique_id, Platform.SELECT ) - async_add_entities([entity]) - return True - return False + + async_add_entities(entities) + return True conn.add_char_factory(async_add_characteristic) diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 5803b8aa839fae..0f481c5c7ee876 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -337,6 +337,14 @@ def thread_status_to_str(char: Characteristic) -> str: state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), + CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION, + name="Valve position", + icon="mdi:pipe-valve", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), } diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 901378c8cb900b..bc61b6fd42eb68 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -102,6 +102,12 @@ "home": "[%key:common::state::home%]", "sleep": "Sleep" } + }, + "temperature_display_units": { + "state": { + "celsius": "Celsius", + "fahrenheit": "Fahrenheit" + } } }, "sensor": { diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index e55bd2782dfd54..4cd6ca143cbf79 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -11,6 +11,6 @@ "iot_class": "local_push", "loggers": ["aiohue"], "quality_scale": "platinum", - "requirements": ["aiohue==4.6.2"], + "requirements": ["aiohue==4.7.0"], "zeroconf": ["_hue._tcp.local."] } diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 0a8f50b8b7af85..1eded0429b81ff 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Hue binary sensors.""" from __future__ import annotations -from typing import Any, TypeAlias +from typing import TypeAlias from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.config import ( @@ -9,9 +9,17 @@ EntertainmentConfigurationController, ) from aiohue.v2.controllers.events import EventType -from aiohue.v2.controllers.sensors import MotionController +from aiohue.v2.controllers.sensors import ( + CameraMotionController, + ContactController, + MotionController, + TamperController, +) +from aiohue.v2.models.camera_motion import CameraMotion +from aiohue.v2.models.contact import Contact, ContactState from aiohue.v2.models.entertainment_configuration import EntertainmentStatus from aiohue.v2.models.motion import Motion +from aiohue.v2.models.tamper import Tamper, TamperState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -25,8 +33,16 @@ from ..const import DOMAIN from .entity import HueBaseEntity -SensorType: TypeAlias = Motion | EntertainmentConfiguration -ControllerType: TypeAlias = MotionController | EntertainmentConfigurationController +SensorType: TypeAlias = ( + CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper +) +ControllerType: TypeAlias = ( + CameraMotionController + | ContactController + | MotionController + | EntertainmentConfigurationController + | TamperController +) async def async_setup_entry( @@ -57,8 +73,11 @@ def async_add_sensor(event_type: EventType, resource: SensorType) -> None: ) # setup for each binary-sensor-type hue resource + register_items(api.sensors.camera_motion, HueMotionSensor) register_items(api.sensors.motion, HueMotionSensor) register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor) + register_items(api.sensors.contact, HueContactSensor) + register_items(api.sensors.tamper, HueTamperSensor) class HueBinarySensorBase(HueBaseEntity, BinarySensorEntity): @@ -87,12 +106,7 @@ def is_on(self) -> bool | None: if not self.resource.enabled: # Force None (unknown) if the sensor is set to disabled in Hue return None - return self.resource.motion.motion - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the optional state attributes.""" - return {"motion_valid": self.resource.motion.motion_valid} + return self.resource.motion.value class HueEntertainmentActiveSensor(HueBinarySensorBase): @@ -110,3 +124,30 @@ def name(self) -> str: """Return sensor name.""" type_title = self.resource.type.value.replace("_", " ").title() return f"{self.resource.metadata.name}: {type_title}" + + +class HueContactSensor(HueBinarySensorBase): + """Representation of a Hue Contact sensor.""" + + _attr_device_class = BinarySensorDeviceClass.OPENING + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if not self.resource.enabled: + # Force None (unknown) if the sensor is set to disabled in Hue + return None + return self.resource.contact_report.state != ContactState.CONTACT + + +class HueTamperSensor(HueBinarySensorBase): + """Representation of a Hue Tamper sensor.""" + + _attr_device_class = BinarySensorDeviceClass.TAMPER + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if not self.resource.tamper_reports: + return False + return self.resource.tamper_reports[0].state == TamperState.TAMPERED diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index cc36edb88b2976..4bfb727b917746 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -100,12 +100,7 @@ class HueTemperatureSensor(HueSensorBase): @property def native_value(self) -> float: """Return the value reported by the sensor.""" - return round(self.resource.temperature.temperature, 1) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the optional state attributes.""" - return {"temperature_valid": self.resource.temperature.temperature_valid} + return round(self.resource.temperature.value, 1) class HueLightLevelSensor(HueSensorBase): @@ -122,14 +117,13 @@ def native_value(self) -> int: # scale used because the human eye adjusts to light levels and small # changes at low lux levels are more noticeable than at high lux # levels. - return int(10 ** ((self.resource.light.light_level - 1) / 10000)) + return int(10 ** ((self.resource.light.value - 1) / 10000)) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" return { - "light_level": self.resource.light.light_level, - "light_level_valid": self.resource.light.light_level_valid, + "light_level": self.resource.light.value, } @@ -149,6 +143,8 @@ def native_value(self) -> int: @property def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" + if self.resource.power_state.battery_state is None: + return {} return {"battery_state": self.resource.power_state.battery_state.value} diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 560046e9c2b9bc..bc3c62cfb9f7d5 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,7 +1,7 @@ """Support for Hydrawise cloud.""" -from pydrawise.legacy import LegacyHydrawise +from pydrawise import legacy from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol @@ -54,7 +54,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Set up Hydrawise from a config entry.""" access_token = config_entry.data[CONF_API_KEY] try: - hydrawise = await hass.async_add_executor_job(LegacyHydrawise, access_token) + hydrawise = await hass.async_add_executor_job( + legacy.LegacyHydrawise, access_token + ) except (ConnectTimeout, HTTPError) as ex: LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) raise ConfigEntryNotReady( diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 06683ff0345998..1c40b16926d36c 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -23,14 +23,13 @@ BINARY_SENSOR_STATUS = BinarySensorEntityDescription( key="status", - name="Status", device_class=BinarySensorDeviceClass.CONNECTIVITY, ) BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="is_watering", - name="Watering", + translation_key="watering", device_class=BinarySensorDeviceClass.MOISTURE, ), ) @@ -77,6 +76,7 @@ async def async_setup_entry( data=hydrawise.current_controller, coordinator=coordinator, description=BINARY_SENSOR_STATUS, + device_id_key="controller_id", ) ] diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index ccf3eb5bac09fa..dc53d847b1f804 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -11,6 +11,8 @@ DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = 15 +MANUFACTURER = "Hydrawise" + SCAN_INTERVAL = timedelta(seconds=120) SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 98b66069913b85..c3f295e1c4ddbe 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -3,9 +3,11 @@ from typing import Any +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN, MANUFACTURER from .coordinator import HydrawiseDataUpdateCoordinator @@ -13,6 +15,7 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): """Entity class for Hydrawise devices.""" _attr_attribution = "Data provided by hydrawise.com" + _attr_has_entity_name = True def __init__( self, @@ -20,14 +23,16 @@ def __init__( data: dict[str, Any], coordinator: HydrawiseDataUpdateCoordinator, description: EntityDescription, + device_id_key: str = "relay_id", ) -> None: """Initialize the Hydrawise entity.""" super().__init__(coordinator=coordinator) self.data = data self.entity_description = description - self._attr_name = f"{self.data['name']} {description.name}" - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - return {"identifier": self.data.get("relay")} + self._device_id = str(data.get(device_id_key)) + self._attr_unique_id = f"{self._device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + name=data["name"], + manufacturer=MANUFACTURER, + ) diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index bcf178744c836b..a5bd9251a3381e 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -24,12 +24,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="next_cycle", - name="Next Cycle", + translation_key="next_cycle", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( key="watering_time", - name="Watering Time", + translation_key="watering_time", icon="mdi:water-pump", native_unit_of_measurement=UnitOfTime.MINUTES, ), diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index 50d3fbaf4c3a21..8f079abcc7d2bf 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -21,5 +21,28 @@ "title": "The Hydrawise YAML configuration import failed", "description": "Configuring Hydrawise using YAML is being removed but there was an {error_type} error importing your YAML configuration.\n\nEnsure connection to Hydrawise works and restart Home Assistant to try again or remove the Hydrawise YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." } + }, + "entity": { + "binary_sensor": { + "watering": { + "name": "Watering" + } + }, + "sensor": { + "next_cycle": { + "name": "Next cycle" + }, + "watering_time": { + "name": "Watering time" + } + }, + "switch": { + "auto_watering": { + "name": "Automatic watering" + }, + "manual_watering": { + "name": "Manual watering" + } + } } } diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 88112d8e27a648..8cdb5b675610f5 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -31,12 +31,12 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( key="auto_watering", - name="Automatic Watering", + translation_key="auto_watering", device_class=SwitchDeviceClass.SWITCH, ), SwitchEntityDescription( key="manual_watering", - name="Manual Watering", + translation_key="manual_watering", device_class=SwitchDeviceClass.SWITCH, ), ) diff --git a/homeassistant/components/input_button/services.yaml b/homeassistant/components/input_button/services.yaml index 7c57fcff272155..8e737ac7055455 100644 --- a/homeassistant/components/input_button/services.yaml +++ b/homeassistant/components/input_button/services.yaml @@ -2,3 +2,5 @@ press: target: entity: domain: input_button + +reload: diff --git a/homeassistant/components/input_button/strings.json b/homeassistant/components/input_button/strings.json index b51d04926f5d2d..d36871917a9068 100644 --- a/homeassistant/components/input_button/strings.json +++ b/homeassistant/components/input_button/strings.json @@ -18,6 +18,10 @@ "press": { "name": "Press", "description": "Mimics the physical button press on the device." + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads helpers from the YAML-configuration." } } } diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 55c4947fe4a04d..f0cf36b5607fbb 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import TypedDict import voluptuous as vol @@ -62,7 +63,7 @@ ) -async def async_reload(hass: HomeAssistant, servie_call: ServiceCall) -> None: +async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None: """Handle start Intent Script service call.""" new_config = await async_integration_yaml_config(hass, DOMAIN) existing_intents = hass.data[DOMAIN] @@ -79,7 +80,7 @@ async def async_reload(hass: HomeAssistant, servie_call: ServiceCall) -> None: async_load_intents(hass, new_intents) -def async_load_intents(hass: HomeAssistant, intents: dict): +def async_load_intents(hass: HomeAssistant, intents: dict[str, ConfigType]) -> None: """Load YAML intents into the intent system.""" template.attach(hass, intents) hass.data[DOMAIN] = intents @@ -98,8 +99,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_load_intents(hass, intents) - async def _handle_reload(servie_call: ServiceCall) -> None: - return await async_reload(hass, servie_call) + async def _handle_reload(service_call: ServiceCall) -> None: + return await async_reload(hass, service_call) service.async_register_admin_service( hass, @@ -111,22 +112,41 @@ async def _handle_reload(servie_call: ServiceCall) -> None: return True +class _IntentSpeechRepromptData(TypedDict): + """Intent config data type for speech or reprompt info.""" + + content: template.Template + title: template.Template + text: template.Template + type: str + + +class _IntentCardData(TypedDict): + """Intent config data type for card info.""" + + type: str + title: template.Template + content: template.Template + + class ScriptIntentHandler(intent.IntentHandler): """Respond to an intent with a script.""" - def __init__(self, intent_type, config): + def __init__(self, intent_type: str, config: ConfigType) -> None: """Initialize the script intent handler.""" self.intent_type = intent_type self.config = config - async def async_handle(self, intent_obj: intent.Intent): + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" - speech = self.config.get(CONF_SPEECH) - reprompt = self.config.get(CONF_REPROMPT) - card = self.config.get(CONF_CARD) - action = self.config.get(CONF_ACTION) - is_async_action = self.config.get(CONF_ASYNC_ACTION) - slots = {key: value["value"] for key, value in intent_obj.slots.items()} + speech: _IntentSpeechRepromptData | None = self.config.get(CONF_SPEECH) + reprompt: _IntentSpeechRepromptData | None = self.config.get(CONF_REPROMPT) + card: _IntentCardData | None = self.config.get(CONF_CARD) + action: script.Script | None = self.config.get(CONF_ACTION) + is_async_action: bool = self.config[CONF_ASYNC_ACTION] + slots: dict[str, str] = { + key: value["value"] for key, value in intent_obj.slots.items() + } _LOGGER.debug( "Intent named %s received with slots: %s", @@ -150,23 +170,23 @@ async def async_handle(self, intent_obj: intent.Intent): if speech is not None: response.async_set_speech( - speech[CONF_TEXT].async_render(slots, parse_result=False), - speech[CONF_TYPE], + speech["text"].async_render(slots, parse_result=False), + speech["type"], ) if reprompt is not None: - text_reprompt = reprompt[CONF_TEXT].async_render(slots, parse_result=False) + text_reprompt = reprompt["text"].async_render(slots, parse_result=False) if text_reprompt: response.async_set_reprompt( text_reprompt, - reprompt[CONF_TYPE], + reprompt["type"], ) if card is not None: response.async_set_card( - card[CONF_TITLE].async_render(slots, parse_result=False), - card[CONF_CONTENT].async_render(slots, parse_result=False), - card[CONF_TYPE], + card["title"].async_render(slots, parse_result=False), + card["content"].async_render(slots, parse_result=False), + card["type"], ) return response diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 4fea047e834dcb..0d7df3fcf9225a 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ipma", "iot_class": "cloud_polling", "loggers": ["geopy", "pyipma"], - "requirements": ["pyipma==3.0.6"] + "requirements": ["pyipma==3.0.7"] } diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index 7f5782f3f89d54..cb0620ceca0d15 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -8,6 +8,8 @@ from pyipma.api import IPMA_API from pyipma.location import Location +from pyipma.rcm import RCM +from pyipma.uv import UV from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry @@ -33,19 +35,32 @@ class IPMASensorEntityDescription(SensorEntityDescription, IPMARequiredKeysMixin """Describes IPMA sensor entity.""" -async def async_retrive_rcm(location: Location, api: IPMA_API) -> int | None: +async def async_retrieve_rcm(location: Location, api: IPMA_API) -> int | None: """Retrieve RCM.""" - fire_risk = await location.fire_risk(api) + fire_risk: RCM = await location.fire_risk(api) if fire_risk: return fire_risk.rcm return None +async def async_retrieve_uvi(location: Location, api: IPMA_API) -> int | None: + """Retrieve UV.""" + uv_risk: UV = await location.uv_risk(api) + if uv_risk: + return round(uv_risk.iUv) + return None + + SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( IPMASensorEntityDescription( key="rcm", translation_key="fire_risk", - value_fn=async_retrive_rcm, + value_fn=async_retrieve_rcm, + ), + IPMASensorEntityDescription( + key="uvi", + translation_key="uv_index", + value_fn=async_retrieve_uvi, ), ) @@ -81,7 +96,7 @@ def __init__( @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: - """Update Fire risk.""" + """Update sensors.""" async with asyncio.timeout(10): self._attr_native_value = await self.entity_description.value_fn( self._location, self._api diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json index b9b672e77d971d..ea5e5ff475927a 100644 --- a/homeassistant/components/ipma/strings.json +++ b/homeassistant/components/ipma/strings.json @@ -28,6 +28,9 @@ "sensor": { "fire_risk": { "name": "Fire risk" + }, + "uv_index": { + "name": "UV index" } } } diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index d8810b0ad45ff8..86ee94f72691cd 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -3,8 +3,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_registry as er from .const import DOMAIN from .coordinator import IslamicPrayerDataUpdateCoordinator @@ -16,6 +16,19 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Islamic Prayer Component.""" + + @callback + def update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if not entity_entry.unique_id.startswith(f"{config_entry.entry_id}-"): + new_unique_id = f"{config_entry.entry_id}-{entity_entry.unique_id}" + return {"new_unique_id": new_unique_id} + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + coordinator = IslamicPrayerDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 80319b83ba212a..a93f2d91d3105b 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -112,19 +112,19 @@ def extra_state_attributes(self) -> dict: other attributes which have been picked up from the event stream and the combined result are returned as the device state attributes. """ - attr = {} + attrs = self._attrs node = self._node # Insteon aux_properties are now their own sensors - if hasattr(self._node, "aux_properties") and node.protocol != PROTO_INSTEON: + # so we no longer need to add them to the attributes + if node.protocol != PROTO_INSTEON and hasattr(node, "aux_properties"): for name, value in self._node.aux_properties.items(): attr_name = COMMAND_FRIENDLY_NAME.get(name, name) - attr[attr_name] = str(value.formatted).lower() + attrs[attr_name] = str(value.formatted).lower() # If a Group/Scene, set a property if the entire scene is on/off - if hasattr(self._node, "group_all_on"): - attr["group_all_on"] = STATE_ON if self._node.group_all_on else STATE_OFF + if hasattr(node, "group_all_on"): + attrs["group_all_on"] = STATE_ON if node.group_all_on else STATE_OFF - self._attrs.update(attr) return self._attrs async def async_send_node_command(self, command: str) -> None: diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index a915d886138f0e..b5c98c7203ab65 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==2.11.2", - "xknxproject==3.2.0", + "xknxproject==3.3.0", "knx-frontend==2023.6.23.191712" ] } diff --git a/homeassistant/components/knx/project.py b/homeassistant/components/knx/project.py index 274ef5cb9a3b6b..d47241b174b64e 100644 --- a/homeassistant/components/knx/project.py +++ b/homeassistant/components/knx/project.py @@ -115,3 +115,7 @@ async def remove_project_file(self) -> None: """Remove project file from storage.""" await self._store.async_remove() self.initial_state() + + async def get_knxproject(self) -> KNXProjectModel | None: + """Load the project file from local storage.""" + return await self._store.async_load() diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index ad29fd19928fdb..e3eb5de8530cfc 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -27,6 +27,7 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_project_file_remove) websocket_api.async_register_command(hass, ws_group_monitor_info) websocket_api.async_register_command(hass, ws_subscribe_telegram) + websocket_api.async_register_command(hass, ws_get_knx_project) if DOMAIN not in hass.data.get("frontend_panels", {}): hass.http.register_static_path( @@ -67,6 +68,7 @@ def ws_info( "name": project_info["name"], "last_modified": project_info["last_modified"], "tool_version": project_info["tool_version"], + "xknxproject_version": project_info["xknxproject_version"], } connection.send_result( @@ -80,6 +82,30 @@ def ws_info( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_knx_project", + } +) +@websocket_api.async_response +async def ws_get_knx_project( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get KNX project.""" + knx: KNXModule = hass.data[DOMAIN] + knxproject = await knx.project.get_knxproject() + connection.send_result( + msg["id"], + { + "project_loaded": knx.project.loaded, + "knxproject": knxproject, + }, + ) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index d69b709c6be421..7b936eaad1abe4 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.12.0", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.12.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/life360/button.py b/homeassistant/components/life360/button.py index 6b460c8531c403..07ef4d06ed970a 100644 --- a/homeassistant/components/life360/button.py +++ b/homeassistant/components/life360/button.py @@ -19,12 +19,10 @@ async def async_setup_entry( coordinator: Life360DataUpdateCoordinator = hass.data[DOMAIN].coordinators[ config_entry.entry_id ] - for member_id, member in coordinator.data.members.items(): - async_add_entities( - [ - Life360UpdateLocationButton(coordinator, member.circle_id, member_id), - ] - ) + async_add_entities( + Life360UpdateLocationButton(coordinator, member.circle_id, member_id) + for member_id, member in coordinator.data.members.items() + ) class Life360UpdateLocationButton( diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index b7b9bdc8521078..f76901ddb057a6 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -64,7 +64,6 @@ def __init__( ) super().__init__(coordinator) - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callback for reachability.""" await super().async_added_to_hass() diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index e7f3d6b78f1e03..32b864047a6241 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -6,6 +6,7 @@ import voluptuous as vol +from homeassistant.const import EVENT_LOGGING_CHANGED # noqa: F401 from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -14,7 +15,6 @@ from .const import ( ATTR_LEVEL, DOMAIN, - EVENT_LOGGING_CHANGED, # noqa: F401 LOGGER_DEFAULT, LOGGER_FILTERS, LOGGER_LOGS, diff --git a/homeassistant/components/logger/const.py b/homeassistant/components/logger/const.py index 06f2af4f3f5fdc..4a7edfaceade98 100644 --- a/homeassistant/components/logger/const.py +++ b/homeassistant/components/logger/const.py @@ -35,8 +35,6 @@ ATTR_LEVEL = "level" -EVENT_LOGGING_CHANGED = "logging_changed" - STORAGE_KEY = "core.logger" STORAGE_LOG_KEY = "logs" STORAGE_VERSION = 1 diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 49996408a1d69c..87ec2cc8cd5256 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -9,6 +9,7 @@ import logging from typing import Any, cast +from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -16,7 +17,6 @@ from .const import ( DOMAIN, - EVENT_LOGGING_CHANGED, LOGGER_DEFAULT, LOGGER_LOGS, LOGSEVERITY, diff --git a/homeassistant/components/london_underground/coordinator.py b/homeassistant/components/london_underground/coordinator.py index a094d099896835..2d3fd6b970f91a 100644 --- a/homeassistant/components/london_underground/coordinator.py +++ b/homeassistant/components/london_underground/coordinator.py @@ -3,7 +3,11 @@ import asyncio import logging +from typing import cast +from london_tube_status import TubeData + +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, SCAN_INTERVAL @@ -11,10 +15,10 @@ _LOGGER = logging.getLogger(__name__) -class LondonTubeCoordinator(DataUpdateCoordinator): +class LondonTubeCoordinator(DataUpdateCoordinator[dict[str, dict[str, str]]]): """London Underground sensor coordinator.""" - def __init__(self, hass, data): + def __init__(self, hass: HomeAssistant, data: TubeData) -> None: """Initialize coordinator.""" super().__init__( hass, @@ -24,7 +28,7 @@ def __init__(self, hass, data): ) self._data = data - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, dict[str, str]]: async with asyncio.timeout(10): await self._data.update() - return self._data.data + return cast(dict[str, dict[str, str]], self._data.data) diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index c0d0eeca372f83..3f5ec42521efac 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from london_tube_status import TubeData import voluptuous as vol @@ -56,22 +57,22 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): _attr_attribution = "Powered by TfL Open Data" _attr_icon = "mdi:subway" - def __init__(self, coordinator, name): + def __init__(self, coordinator: LondonTubeCoordinator, name: str) -> None: """Initialize the London Underground sensor.""" super().__init__(coordinator) self._name = name @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> str: """Return the state of the sensor.""" return self.coordinator.data[self.name]["State"] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return other details about the sensor state.""" return {"Description": self.coordinator.data[self.name]["Description"]} diff --git a/homeassistant/components/medcom_ble/__init__.py b/homeassistant/components/medcom_ble/__init__.py new file mode 100644 index 00000000000000..a129c4fc7f970c --- /dev/null +++ b/homeassistant/components/medcom_ble/__init__.py @@ -0,0 +1,74 @@ +"""The Medcom BLE integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from bleak import BleakError +from medcom_ble import MedcomBleDeviceData + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +# Supported platforms +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Medcom BLE radiation monitor from a config entry.""" + + address = entry.unique_id + elevation = hass.config.elevation + is_metric = hass.config.units is METRIC_SYSTEM + assert address is not None + + ble_device = bluetooth.async_ble_device_from_address(hass, address) + if not ble_device: + raise ConfigEntryNotReady( + f"Could not find Medcom BLE device with address {address}" + ) + + async def _async_update_method(): + """Get data from Medcom BLE radiation monitor.""" + ble_device = bluetooth.async_ble_device_from_address(hass, address) + inspector = MedcomBleDeviceData(_LOGGER, elevation, is_metric) + + try: + data = await inspector.update_device(ble_device) + except BleakError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + return data + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=_async_update_method, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/medcom_ble/config_flow.py b/homeassistant/components/medcom_ble/config_flow.py new file mode 100644 index 00000000000000..30a87afbb729b4 --- /dev/null +++ b/homeassistant/components/medcom_ble/config_flow.py @@ -0,0 +1,147 @@ +"""Config flow for Medcom BlE integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from bleak import BleakError +from bluetooth_data_tools import human_readable_name +from medcom_ble import MedcomBleDevice, MedcomBleDeviceData +from medcom_ble.const import INSPECTOR_SERVICE_UUID +import voluptuous as vol + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import ( + BluetoothServiceInfo, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import AbortFlow, FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class InspectorBLEConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Medcom BLE radiation monitors.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfo | None = None + self._discovered_devices: dict[str, BluetoothServiceInfo] = {} + + async def _get_device_data( + self, service_info: BluetoothServiceInfo + ) -> MedcomBleDevice: + ble_device = bluetooth.async_ble_device_from_address( + self.hass, service_info.address + ) + if ble_device is None: + _LOGGER.debug("no ble_device in _get_device_data") + raise AbortFlow("cannot_connect") + + inspector = MedcomBleDeviceData(_LOGGER) + + return await inspector.update_device(ble_device) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("Discovered BLE device: %s", discovery_info.name) + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._discovery_info = discovery_info + self.context["title_placeholders"] = { + "name": human_readable_name( + None, discovery_info.name, discovery_info.address + ) + } + + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + # We always will have self._discovery_info be a BluetoothServiceInfo at this point + # and this helps mypy not complain + assert self._discovery_info is not None + + if user_input is None: + name = self._discovery_info.name or self._discovery_info.address + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={"name": name}, + ) + + return await self.async_step_check_connection() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + self._discovery_info = self._discovered_devices[address] + return await self.async_step_check_connection() + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + _LOGGER.debug( + "Detected a device that's already configured: %s", address + ) + continue + + if INSPECTOR_SERVICE_UUID not in discovery_info.service_uuids: + continue + + self._discovered_devices[discovery_info.address] = discovery_info + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + titles = { + address: discovery.name + for address, discovery in self._discovered_devices.items() + } + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In(titles), + }, + ), + ) + + async def async_step_check_connection(self) -> FlowResult: + """Check we can connect to the device before considering the configuration is successful.""" + # We always will have self._discovery_info be a BluetoothServiceInfo at this point + # and this helps mypy not complain + assert self._discovery_info is not None + + _LOGGER.debug("Checking device connection: %s", self._discovery_info.name) + try: + await self._get_device_data(self._discovery_info) + except BleakError: + return self.async_abort(reason="cannot_connect") + except AbortFlow: + raise + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception( + "Error occurred reading information from %s: %s", + self._discovery_info.address, + err, + ) + return self.async_abort(reason="unknown") + _LOGGER.debug("Device connection successful, proceeding") + return self.async_create_entry(title=self._discovery_info.name, data={}) diff --git a/homeassistant/components/medcom_ble/const.py b/homeassistant/components/medcom_ble/const.py new file mode 100644 index 00000000000000..3929b5d302bad0 --- /dev/null +++ b/homeassistant/components/medcom_ble/const.py @@ -0,0 +1,10 @@ +"""Constants for the Medcom BLE integration.""" + +DOMAIN = "medcom_ble" + +# 5 minutes scan interval, which is perfectly +# adequate for background monitoring +DEFAULT_SCAN_INTERVAL = 300 + +# Units for the radiation monitors +UNIT_CPM = "CPM" diff --git a/homeassistant/components/medcom_ble/manifest.json b/homeassistant/components/medcom_ble/manifest.json new file mode 100644 index 00000000000000..4aacae4647d898 --- /dev/null +++ b/homeassistant/components/medcom_ble/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "medcom_ble", + "name": "Medcom Bluetooth", + "bluetooth": [ + { + "service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f" + } + ], + "codeowners": ["@elafargue"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/medcom_ble", + "iot_class": "local_polling", + "requirements": ["medcom-ble==0.1.1"] +} diff --git a/homeassistant/components/medcom_ble/sensor.py b/homeassistant/components/medcom_ble/sensor.py new file mode 100644 index 00000000000000..4c7488ddc125d1 --- /dev/null +++ b/homeassistant/components/medcom_ble/sensor.py @@ -0,0 +1,104 @@ +"""Support for Medcom BLE radiation monitor sensors.""" +from __future__ import annotations + +import logging + +from medcom_ble import MedcomBleDevice + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN, UNIT_CPM + +_LOGGER = logging.getLogger(__name__) + +SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { + "cpm": SensorEntityDescription( + key="cpm", + translation_key="cpm", + native_unit_of_measurement=UNIT_CPM, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:radioactive", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Medcom BLE radiation monitor sensors.""" + + coordinator: DataUpdateCoordinator[MedcomBleDevice] = hass.data[DOMAIN][ + entry.entry_id + ] + + entities = [] + _LOGGER.debug("got sensors: %s", coordinator.data.sensors) + for sensor_type, sensor_value in coordinator.data.sensors.items(): + if sensor_type not in SENSORS_MAPPING_TEMPLATE: + _LOGGER.debug( + "Unknown sensor type detected: %s, %s", + sensor_type, + sensor_value, + ) + continue + entities.append( + MedcomSensor(coordinator, SENSORS_MAPPING_TEMPLATE[sensor_type]) + ) + + async_add_entities(entities) + + +class MedcomSensor( + CoordinatorEntity[DataUpdateCoordinator[MedcomBleDevice]], SensorEntity +): + """Medcom BLE radiation monitor sensors for the device.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator[MedcomBleDevice], + entity_description: SensorEntityDescription, + ) -> None: + """Populate the medcom entity with relevant data.""" + super().__init__(coordinator) + self.entity_description = entity_description + medcom_device = coordinator.data + + name = medcom_device.name + if identifier := medcom_device.identifier: + name += f" ({identifier})" + + self._attr_unique_id = f"{medcom_device.address}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + connections={ + ( + CONNECTION_BLUETOOTH, + medcom_device.address, + ) + }, + name=name, + manufacturer=medcom_device.manufacturer, + hw_version=medcom_device.hw_version, + sw_version=medcom_device.sw_version, + model=medcom_device.model, + ) + + @property + def native_value(self) -> float: + """Return the value reported by the sensor.""" + return self.coordinator.data.sensors[self.entity_description.key] diff --git a/homeassistant/components/medcom_ble/strings.json b/homeassistant/components/medcom_ble/strings.json new file mode 100644 index 00000000000000..6ea6c0566ed6ca --- /dev/null +++ b/homeassistant/components/medcom_ble/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:component::bluetooth::config::step::user::data::address%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "sensor": { + "cpm": { + "name": "Counts per minute" + } + } + } +} diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index dae734fc06fdb7..328871cf78c782 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -135,11 +135,10 @@ def stream_selector(query: str) -> str: raise MEQueryException() from err if "formats" in requested_stream: - best_stream = requested_stream["formats"][ - len(requested_stream["formats"]) - 1 - ] - return str(best_stream["url"]) - return str(requested_stream["url"]) + if requested_stream["extractor"] == "youtube": + return get_best_stream_youtube(requested_stream["formats"]) + return get_best_stream(requested_stream["formats"]) + return cast(str, requested_stream["url"]) return stream_selector @@ -154,7 +153,7 @@ def call_media_player_service( except MEQueryException: _LOGGER.error("Wrong query format: %s", stream_query) return - + _LOGGER.debug("Selected the following stream: %s", stream_url) data = {k: v for k, v in self.call_data.items() if k != ATTR_ENTITY_ID} data[ATTR_MEDIA_CONTENT_ID] = stream_url @@ -181,3 +180,29 @@ def get_stream_query_for_entity(self, entity_id: str | None) -> str: ) return default_stream_query + + +def get_best_stream(formats: list[dict[str, Any]]) -> str: + """Return the best quality stream. + + As per + https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/common.py#L128. + """ + + return cast(str, formats[len(formats) - 1]["url"]) + + +def get_best_stream_youtube(formats: list[dict[str, Any]]) -> str: + """YouTube responses also include files with only video or audio. + + So we filter on files with both audio and video codec. + """ + + return get_best_stream( + [ + format + for format in formats + if format.get("acodec", "none") != "none" + and format.get("vcodec", "none") != "none" + ] + ) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 707cbdf9e8beb6..37a8a0d67735ac 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -7,5 +7,5 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2023.7.6"] + "requirements": ["yt-dlp==2023.9.24"] } diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index b7326735be9bfc..7f2b08c96ef306 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -4,8 +4,10 @@ import logging from typing import Any +from mcstatus import JavaServer + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, Platform +from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er @@ -20,20 +22,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" - _LOGGER.debug( - "Creating coordinator instance for '%s' (%s)", - entry.data[CONF_NAME], - entry.data[CONF_HOST], - ) # Create coordinator instance. - config_entry_id = entry.entry_id - coordinator = MinecraftServerCoordinator(hass, config_entry_id, entry.data) + coordinator = MinecraftServerCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() # Store coordinator instance. domain_data = hass.data.setdefault(DOMAIN, {}) - domain_data[config_entry_id] = coordinator + domain_data[entry.entry_id] = coordinator # Set up platforms. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -43,7 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Minecraft Server config entry.""" - config_entry_id = config_entry.entry_id # Unload platforms. unload_ok = await hass.config_entries.async_unload_platforms( @@ -51,17 +46,18 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) # Clean up. - hass.data[DOMAIN].pop(config_entry_id) + hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old config entry to a new format.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) # 1 --> 2: Use config entry ID as base for unique IDs. if config_entry.version == 1: + _LOGGER.debug("Migrating from version 1") + old_unique_id = config_entry.unique_id assert old_unique_id config_entry_id = config_entry.entry_id @@ -78,7 +74,52 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Migrate entities. await er.async_migrate_entries(hass, config_entry_id, _migrate_entity_unique_id) - _LOGGER.debug("Migration to version %s successful", config_entry.version) + _LOGGER.debug("Migration to version 2 successful") + + # 2 --> 3: Use address instead of host and port in config entry. + if config_entry.version == 2: + _LOGGER.debug("Migrating from version 2") + + config_data = config_entry.data + + # Migrate config entry. + try: + address = config_data[CONF_HOST] + JavaServer.lookup(address) + host_only_lookup_success = True + except ValueError as error: + host_only_lookup_success = False + _LOGGER.debug( + "Hostname (without port) cannot be parsed (error: %s), trying again with port", + error, + ) + + if not host_only_lookup_success: + try: + address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" + JavaServer.lookup(address) + except ValueError as error: + _LOGGER.exception( + "Can't migrate configuration entry due to error while parsing server address (error: %s), try again later", + error, + ) + return False + + _LOGGER.debug( + "Migrating config entry, replacing host '%s' and port '%s' with address '%s'", + config_data[CONF_HOST], + config_data[CONF_PORT], + address, + ) + + new_data = config_data.copy() + new_data[CONF_ADDRESS] = address + del new_data[CONF_HOST] + del new_data[CONF_PORT] + config_entry.version = 3 + hass.config_entries.async_update_entry(config_entry, data=new_data) + + _LOGGER.debug("Migration to version 3 successful") return True diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index f4b4212bc64bef..527dfa1ed04834 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,18 +1,16 @@ """Config flow for Minecraft Server integration.""" -from contextlib import suppress import logging from mcstatus import JavaServer import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.data_entry_flow import FlowResult -from . import helpers -from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_NAME, DOMAIN -DEFAULT_HOST = "localhost:25565" +DEFAULT_ADDRESS = "localhost:25565" _LOGGER = logging.getLogger(__name__) @@ -20,51 +18,22 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Minecraft Server.""" - VERSION = 2 + VERSION = 3 async def async_step_user(self, user_input=None) -> FlowResult: """Handle the initial step.""" errors = {} - if user_input is not None: - host = None - port = DEFAULT_PORT - title = user_input[CONF_HOST] + if user_input: + address = user_input[CONF_ADDRESS] - # Split address at last occurrence of ':'. - address_left, separator, address_right = user_input[CONF_HOST].rpartition( - ":" - ) - - # If no separator is found, 'rpartition' returns ('', '', original_string). - if separator == "": - host = address_right - else: - host = address_left - with suppress(ValueError): - port = int(address_right) - - # Remove '[' and ']' in case of an IPv6 address. - host = host.strip("[]") - - # Validate port configuration (limit to user and dynamic port range). - if (port < 1024) or (port > 65535): - errors["base"] = "invalid_port" - # Validate host and port by checking the server connection. - else: - # Create server instance with configuration data and ping the server. - config_data = { - CONF_NAME: user_input[CONF_NAME], - CONF_HOST: host, - CONF_PORT: port, - } - if await self._async_is_server_online(host, port): - # Configuration data are available and no error was detected, - # create configuration entry. - return self.async_create_entry(title=title, data=config_data) + if await self._async_is_server_online(address): + # No error was detected, create configuration entry. + config_data = {CONF_NAME: user_input[CONF_NAME], CONF_ADDRESS: address} + return self.async_create_entry(title=address, data=config_data) - # Host or port invalid or server not reachable. - errors["base"] = "cannot_connect" + # Host or port invalid or server not reachable. + errors["base"] = "cannot_connect" # Show configuration form (default form in case of no user_input, # form filled with user_input and eventually with errors otherwise). @@ -83,24 +52,32 @@ def _show_config_form(self, user_input=None, errors=None) -> FlowResult: CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) ): str, vol.Required( - CONF_HOST, default=user_input.get(CONF_HOST, DEFAULT_HOST) + CONF_ADDRESS, + default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS), ): vol.All(str, vol.Lower), } ), errors=errors, ) - async def _async_is_server_online(self, host: str, port: int) -> bool: + async def _async_is_server_online(self, address: str) -> bool: """Check server connection using a 'status' request and return result.""" - # Check if host is a SRV record. If so, update server data. - if srv_record := await helpers.async_check_srv_record(host): - # Use extracted host and port from SRV record. - host = srv_record[CONF_HOST] - port = srv_record[CONF_PORT] + # Parse and check server address. + try: + server = await JavaServer.async_lookup(address) + except ValueError as error: + _LOGGER.debug( + ( + "Error occurred while parsing server address '%s' -" + " ValueError: %s" + ), + address, + error, + ) + return False # Send a status request to the server. - server = JavaServer(host, port) try: await server.async_status() return True @@ -110,8 +87,8 @@ async def _async_is_server_online(self, host: str, port: int) -> bool: "Error occurred while trying to check the connection to '%s:%s' -" " OSError: %s" ), - host, - port, + server.address.host, + server.address.port, error, ) diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index 9f14f429a12c1b..e7a58741696d47 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -1,7 +1,6 @@ """Constants for the Minecraft Server integration.""" DEFAULT_NAME = "Minecraft Server" -DEFAULT_PORT = 25565 DOMAIN = "minecraft_server" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 178c12772c6686..9b5ab1fbb43683 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -1,20 +1,18 @@ """The Minecraft Server integration.""" from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any from mcstatus.server import JavaServer -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from . import helpers - SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -36,12 +34,11 @@ class MinecraftServerData: class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Minecraft Server data update coordinator.""" - _srv_record_checked = False - - def __init__( - self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any] - ) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize coordinator instance.""" + config_data = config_entry.data + self.unique_id = config_entry.entry_id + super().__init__( hass=hass, name=config_data[CONF_NAME], @@ -49,34 +46,20 @@ def __init__( update_interval=SCAN_INTERVAL, ) - # Server data - self.unique_id = unique_id - self._host = config_data[CONF_HOST] - self._port = config_data[CONF_PORT] - - # 3rd party library instance - self._server = JavaServer(self._host, self._port) + try: + self._server = JavaServer.lookup(config_data[CONF_ADDRESS]) + except ValueError as error: + raise HomeAssistantError( + f"Address in configuration entry cannot be parsed (error: {error}), please remove this device and add it again" + ) from error async def _async_update_data(self) -> MinecraftServerData: """Get server data from 3rd party library and update properties.""" - - # Check once if host is a valid Minecraft SRV record. - if not self._srv_record_checked: - self._srv_record_checked = True - if srv_record := await helpers.async_check_srv_record(self._host): - # Overwrite host, port and 3rd party library instance - # with data extracted out of the SRV record. - self._host = srv_record[CONF_HOST] - self._port = srv_record[CONF_PORT] - self._server = JavaServer(self._host, self._port) - - # Send status request to the server. try: status_response = await self._server.async_status() except OSError as error: raise UpdateFailed(error) from error - # Got answer to request, update properties. players_list = [] if players := status_response.players.sample: for player in players: diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py deleted file mode 100644 index f5991620c68515..00000000000000 --- a/homeassistant/components/minecraft_server/helpers.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Helper functions of Minecraft Server integration.""" -import logging -from typing import Any - -import aiodns - -from homeassistant.const import CONF_HOST, CONF_PORT - -SRV_RECORD_PREFIX = "_minecraft._tcp" - -_LOGGER = logging.getLogger(__name__) - - -async def async_check_srv_record(host: str) -> dict[str, Any] | None: - """Check if the given host is a valid Minecraft SRV record.""" - srv_record = None - - try: - srv_query = await aiodns.DNSResolver().query( - host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" - ) - except aiodns.error.DNSError: - # 'host' is not a Minecraft SRV record. - pass - else: - # 'host' is a valid Minecraft SRV record, extract the data. - srv_record = { - CONF_HOST: srv_query[0].host, - CONF_PORT: srv_query[0].port, - } - _LOGGER.debug( - "'%s' is a valid Minecraft SRV record ('%s:%s')", - host, - srv_record[CONF_HOST], - srv_record[CONF_PORT], - ) - - return srv_record diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index 758f22b1e9a795..6f11d34cccb9de 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], "quality_scale": "silver", - "requirements": ["aiodns==3.0.0", "mcstatus==11.0.0"] + "requirements": ["mcstatus==11.0.0"] } diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index b64c96f580b331..c5fe5b81d817e6 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -6,13 +6,12 @@ "description": "Set up your Minecraft Server instance to allow monitoring.", "data": { "name": "[%key:common::config_flow::data::name%]", - "host": "[%key:common::config_flow::data::host%]" + "address": "Server address" } } }, "error": { - "invalid_port": "Port must be in range from 1024 to 65535. Please correct it and try again.", - "cannot_connect": "Failed to connect to server. Please check the host and port and try again. Also ensure that you are running at least Minecraft version 1.7 on your server." + "cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. Also ensure that you are running at least version 1.7 of Minecraft Java Edition on your server." } }, "entity": { diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 188f3a784acba1..45b1e42c8bb0b4 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -2,19 +2,16 @@ import asyncio from datetime import timedelta import logging -from socket import timeout -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from motionblinds import AsyncMotionMulticast, ParseException +from motionblinds import AsyncMotionMulticast from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( - ATTR_AVAILABLE, CONF_INTERFACE, CONF_WAIT_FOR_PUSH, DEFAULT_INTERFACE, @@ -28,85 +25,13 @@ KEY_UNSUB_STOP, PLATFORMS, UPDATE_INTERVAL, - UPDATE_INTERVAL_FAST, ) +from .coordinator import DataUpdateCoordinatorMotionBlinds from .gateway import ConnectMotionGateway _LOGGER = logging.getLogger(__name__) -class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): - """Class to manage fetching data from single endpoint.""" - - def __init__( - self, - hass: HomeAssistant, - logger: logging.Logger, - coordinator_info: dict[str, Any], - *, - name: str, - update_interval: timedelta, - ) -> None: - """Initialize global data updater.""" - super().__init__( - hass, - logger, - name=name, - update_interval=update_interval, - ) - - self.api_lock = coordinator_info[KEY_API_LOCK] - self._gateway = coordinator_info[KEY_GATEWAY] - self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH] - - def update_gateway(self): - """Fetch data from gateway.""" - try: - self._gateway.Update() - except (timeout, ParseException): - # let the error be logged and handled by the motionblinds library - return {ATTR_AVAILABLE: False} - - return {ATTR_AVAILABLE: True} - - def update_blind(self, blind): - """Fetch data from a blind.""" - try: - if self._wait_for_push: - blind.Update() - else: - blind.Update_trigger() - except (timeout, ParseException): - # let the error be logged and handled by the motionblinds library - return {ATTR_AVAILABLE: False} - - return {ATTR_AVAILABLE: True} - - async def _async_update_data(self): - """Fetch the latest data from the gateway and blinds.""" - data = {} - - async with self.api_lock: - data[KEY_GATEWAY] = await self.hass.async_add_executor_job( - self.update_gateway - ) - - for blind in self._gateway.device_list.values(): - await asyncio.sleep(1.5) - async with self.api_lock: - data[blind.mac] = await self.hass.async_add_executor_job( - self.update_blind, blind - ) - - all_available = all(device[ATTR_AVAILABLE] for device in data.values()) - if all_available: - self.update_interval = timedelta(seconds=UPDATE_INTERVAL) - else: - self.update_interval = timedelta(seconds=UPDATE_INTERVAL_FAST) - - return data - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the motion_blinds components from a config entry.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py new file mode 100644 index 00000000000000..cfc7d319b38675 --- /dev/null +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -0,0 +1,94 @@ +"""DataUpdateCoordinator for motion blinds integration.""" +import asyncio +from datetime import timedelta +import logging +from socket import timeout +from typing import Any + +from motionblinds import ParseException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + ATTR_AVAILABLE, + CONF_WAIT_FOR_PUSH, + KEY_API_LOCK, + KEY_GATEWAY, + UPDATE_INTERVAL, + UPDATE_INTERVAL_FAST, +) + +_LOGGER = logging.getLogger(__name__) + + +class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): + """Class to manage fetching data from single endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + coordinator_info: dict[str, Any], + *, + name: str, + update_interval: timedelta, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + ) + + self.api_lock = coordinator_info[KEY_API_LOCK] + self._gateway = coordinator_info[KEY_GATEWAY] + self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH] + + def update_gateway(self): + """Fetch data from gateway.""" + try: + self._gateway.Update() + except (timeout, ParseException): + # let the error be logged and handled by the motionblinds library + return {ATTR_AVAILABLE: False} + + return {ATTR_AVAILABLE: True} + + def update_blind(self, blind): + """Fetch data from a blind.""" + try: + if self._wait_for_push: + blind.Update() + else: + blind.Update_trigger() + except (timeout, ParseException): + # let the error be logged and handled by the motionblinds library + return {ATTR_AVAILABLE: False} + + return {ATTR_AVAILABLE: True} + + async def _async_update_data(self): + """Fetch the latest data from the gateway and blinds.""" + data = {} + + async with self.api_lock: + data[KEY_GATEWAY] = await self.hass.async_add_executor_job( + self.update_gateway + ) + + for blind in self._gateway.device_list.values(): + await asyncio.sleep(1.5) + async with self.api_lock: + data[blind.mac] = await self.hass.async_add_executor_job( + self.update_blind, blind + ) + + all_available = all(device[ATTR_AVAILABLE] for device in data.values()) + if all_available: + self.update_interval = timedelta(seconds=UPDATE_INTERVAL) + else: + self.update_interval = timedelta(seconds=UPDATE_INTERVAL_FAST) + + return data diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py index 8f3ac05228dae4..56eccb04eae447 100644 --- a/homeassistant/components/motion_blinds/entity.py +++ b/homeassistant/components/motion_blinds/entity.py @@ -8,7 +8,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DataUpdateCoordinatorMotionBlinds from .const import ( ATTR_AVAILABLE, DEFAULT_GATEWAY_NAME, @@ -16,6 +15,7 @@ KEY_GATEWAY, MANUFACTURER, ) +from .coordinator import DataUpdateCoordinatorMotionBlinds from .gateway import device_name diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 5b5c39e6831c7f..7caeb2b51f7f29 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -24,7 +24,7 @@ SERVICE_RELOAD, ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import TemplateError, Unauthorized +from homeassistant.exceptions import HomeAssistantError, TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event as ev, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -364,8 +364,15 @@ async def async_setup_reload_service() -> None: async def _reload_config(call: ServiceCall) -> None: """Reload the platforms.""" - # Fetch updated manual configured items and validate - config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} + # Fetch updated manually configured items and validate + if ( + config_yaml := await async_integration_yaml_config(hass, DOMAIN) + ) is None: + # Raise in case we have an invalid configuration + raise HomeAssistantError( + "Error reloading manually configured MQTT items, " + "check your configuration.yaml" + ) mqtt_data.config = config_yaml.get(DOMAIN, {}) # Reload the modern yaml platforms diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 2bfaa7d191363a..3600d9663dd843 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -158,16 +158,6 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): _entity_id_format = alarm.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_ALARM_ATTRIBUTES_BLOCKED - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Init the MQTT Alarm Control Panel.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 505305cad3e39c..c0f4cc7786e409 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -98,22 +98,11 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): """Representation a binary sensor that is updated by MQTT.""" _default_name = DEFAULT_NAME + _delay_listener: CALLBACK_TYPE | None = None _entity_id_format = binary_sensor.ENTITY_ID_FORMAT _expired: bool | None _expire_after: int | None - - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT binary sensor.""" - self._expiration_trigger: CALLBACK_TYPE | None = None - self._delay_listener: CALLBACK_TYPE | None = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + _expiration_trigger: CALLBACK_TYPE | None = None async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 9b3b04a54f5c62..47ac12386f78f8 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -73,16 +73,6 @@ class MqttButton(MqttEntity, ButtonEntity): _default_name = DEFAULT_NAME _entity_id_format = button.ENTITY_ID_FORMAT - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT button.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index edddd0f2239be7..c8402e501b0edf 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -84,6 +84,7 @@ class MqttCamera(MqttEntity, Camera): _default_name = DEFAULT_NAME _entity_id_format: str = camera.ENTITY_ID_FORMAT _attributes_extra_blocked: frozenset[str] = MQTT_CAMERA_ATTRIBUTES_BLOCKED + _last_image: bytes | None = None def __init__( self, @@ -93,8 +94,6 @@ def __init__( discovery_data: DiscoveryInfoType | None, ) -> None: """Initialize the MQTT Camera.""" - self._last_image: bytes | None = None - Camera.__init__(self) MqttEntity.__init__(self, hass, config, config_entry, discovery_data) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 733645c4788b41..02f3edd155a3ad 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Callable, Coroutine, Iterable +from dataclasses import dataclass from functools import lru_cache from itertools import chain, groupby import logging @@ -12,7 +13,6 @@ from typing import TYPE_CHECKING, Any import uuid -import attr import certifi from homeassistant.config_entries import ConfigEntry @@ -212,15 +212,15 @@ def remove() -> None: return remove -@attr.s(slots=True, frozen=True) +@dataclass(frozen=True) class Subscription: """Class to hold data about an active subscription.""" - topic: str = attr.ib() - matcher: Any = attr.ib() - job: HassJob[[ReceiveMessage], Coroutine[Any, Any, None] | None] = attr.ib() - qos: int = attr.ib(default=0) - encoding: str | None = attr.ib(default="utf-8") + topic: str + matcher: Any + job: HassJob[[ReceiveMessage], Coroutine[Any, Any, None] | None] + qos: int = 0 + encoding: str | None = "utf-8" class MqttClientSetup: diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index dfd5d70dca6358..77f28e1b5ca8e1 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -424,28 +424,16 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): climate and water_heater platforms. """ - _attr_target_temperature_low: float | None - _attr_target_temperature_high: float | None + _attr_target_temperature_low: float | None = None + _attr_target_temperature_high: float | None = None + _feature_preset_mode: bool = False _optimistic: bool _topic: dict[str, Any] _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the temperature controlled device.""" - self._attr_target_temperature_low = None - self._attr_target_temperature_high = None - self._feature_preset_mode = False - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - def add_subscription( self, topics: dict[str, dict[str, Any]], @@ -619,27 +607,14 @@ async def async_set_temperature(self, **kwargs: Any) -> None: class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): """Representation of an MQTT climate device.""" + _attr_fan_mode: str | None = None + _attr_hvac_mode: HVACMode | None = None + _attr_is_aux_heat: bool | None = None + _attr_swing_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the climate device.""" - self._attr_fan_mode = None - self._attr_hvac_action = None - self._attr_hvac_mode = None - self._attr_is_aux_heat = None - self._attr_swing_mode = None - MqttTemperatureControlEntity.__init__( - self, hass, config, config_entry, discovery_data - ) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index 6b4b90586a7287..41614a62f30c9c 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -3,12 +3,11 @@ from collections import deque from collections.abc import Callable +from dataclasses import dataclass import datetime as dt from functools import wraps from typing import TYPE_CHECKING, Any -import attr - from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import DiscoveryInfoType @@ -49,15 +48,15 @@ def wrapper(msg: Any) -> None: return _decorator -@attr.s(slots=True, frozen=True) +@dataclass class TimestampedPublishMessage: """MQTT Message.""" - topic: str = attr.ib() - payload: PublishPayloadType = attr.ib() - qos: int = attr.ib() - retain: bool = attr.ib() - timestamp: dt.datetime = attr.ib(default=None) + topic: str + payload: PublishPayloadType + qos: int + retain: bool + timestamp: dt.datetime def log_message( diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index f99eab4d58f6bb..2270f2b4031dfb 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -107,19 +107,9 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): _default_name = None _entity_id_format = device_tracker.ENTITY_ID_FORMAT + _location_name: str | None = None _value_template: Callable[..., ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the tracker.""" - self._location_name: str | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 6f8be33f21af1d..c345655eea521f 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -32,14 +32,18 @@ PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttValueTemplate, PayloadSentinel, ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -104,16 +108,6 @@ class MqttEvent(MqttEntity, EventEntity): _attributes_extra_blocked = MQTT_EVENT_ATTRIBUTES_BLOCKED _template: Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the sensor.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -133,6 +127,7 @@ def _prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"state"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" event_attributes: dict[str, Any] = {} @@ -195,7 +190,6 @@ def message_received(msg: ReceiveMessage) -> None: payload, ) return - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) topics["state_topic"] = { "topic": self._config[CONF_STATE_TOPIC], diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 5c7557c7598d81..0aad3a6afc066a 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -50,7 +50,12 @@ PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MessageCallbackType, MqttCommandTemplate, @@ -59,7 +64,7 @@ ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" @@ -215,6 +220,9 @@ async def _async_setup_entity( class MqttFan(MqttEntity, FanEntity): """A MQTT fan component.""" + _attr_percentage: int | None = None + _attr_preset_mode: str | None = None + _default_name = DEFAULT_NAME _entity_id_format = fan.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_FAN_ATTRIBUTES_BLOCKED @@ -232,19 +240,6 @@ class MqttFan(MqttEntity, FanEntity): _payload: dict[str, Any] _speed_range: tuple[int, int] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT fan.""" - self._attr_percentage = None - self._attr_preset_mode = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -367,6 +362,7 @@ def add_subscribe_topic(topic: str, msg_callback: MessageCallbackType) -> bool: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message.""" payload = self._value_templates[CONF_STATE](msg.payload) @@ -379,12 +375,12 @@ def state_received(msg: ReceiveMessage) -> None: self._attr_is_on = False elif payload == PAYLOAD_NONE: self._attr_is_on = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_STATE_TOPIC, state_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_percentage"}) def percentage_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the percentage.""" rendered_percentage_payload = self._value_templates[ATTR_PERCENTAGE]( @@ -395,7 +391,6 @@ def percentage_received(msg: ReceiveMessage) -> None: return if rendered_percentage_payload == self._payload["PERCENTAGE_RESET"]: self._attr_percentage = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: percentage = ranged_value_to_percentage( @@ -424,18 +419,17 @@ def percentage_received(msg: ReceiveMessage) -> None: ) return self._attr_percentage = percentage - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_PERCENTAGE_STATE_TOPIC, percentage_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_preset_mode"}) def preset_mode_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for preset mode.""" preset_mode = str(self._value_templates[ATTR_PRESET_MODE](msg.payload)) if preset_mode == self._payload["PRESET_MODE_RESET"]: self._attr_preset_mode = None - self.async_write_ha_state() return if not preset_mode: _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) @@ -450,12 +444,12 @@ def preset_mode_received(msg: ReceiveMessage) -> None: return self._attr_preset_mode = preset_mode - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_PRESET_MODE_STATE_TOPIC, preset_mode_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_oscillating"}) def oscillation_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the oscillation.""" payload = self._value_templates[ATTR_OSCILLATING](msg.payload) @@ -466,13 +460,13 @@ def oscillation_received(msg: ReceiveMessage) -> None: self._attr_oscillating = True elif payload == self._payload["OSCILLATE_OFF_PAYLOAD"]: self._attr_oscillating = False - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if add_subscribe_topic(CONF_OSCILLATION_STATE_TOPIC, oscillation_received): self._attr_oscillating = False @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_direction"}) def direction_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the direction.""" direction = self._value_templates[ATTR_DIRECTION](msg.payload) @@ -480,7 +474,6 @@ def direction_received(msg: ReceiveMessage) -> None: _LOGGER.debug("Ignoring empty direction from '%s'", msg.topic) return self._attr_current_direction = str(direction) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_DIRECTION_STATE_TOPIC, direction_received) diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 52d8db3fc9822c..05929ee904a6ad 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -52,7 +52,12 @@ PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -60,7 +65,7 @@ ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic CONF_AVAILABLE_MODES_LIST = "modes" CONF_DEVICE_CLASS = "device_class" @@ -207,6 +212,7 @@ async def _async_setup_entity( class MqttHumidifier(MqttEntity, HumidifierEntity): """A MQTT humidifier component.""" + _attr_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = humidifier.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED @@ -219,18 +225,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): _payload: dict[str, str] _topic: dict[str, Any] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT humidifier.""" - self._attr_mode = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -313,6 +307,7 @@ def _prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message.""" payload = self._value_templates[CONF_STATE](msg.payload) @@ -325,12 +320,12 @@ def state_received(msg: ReceiveMessage) -> None: self._attr_is_on = False elif payload == PAYLOAD_NONE: self._attr_is_on = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_STATE_TOPIC, state_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_action"}) def action_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message.""" action_payload = self._value_templates[ATTR_ACTION](msg.payload) @@ -347,12 +342,12 @@ def action_received(msg: ReceiveMessage) -> None: action_payload, ) return - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_ACTION_TOPIC, action_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_humidity"}) def current_humidity_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the current humidity.""" rendered_current_humidity_payload = self._value_templates[ @@ -360,7 +355,6 @@ def current_humidity_received(msg: ReceiveMessage) -> None: ](msg.payload) if rendered_current_humidity_payload == self._payload["HUMIDITY_RESET"]: self._attr_current_humidity = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if not rendered_current_humidity_payload: _LOGGER.debug("Ignoring empty current humidity from '%s'", msg.topic) @@ -384,7 +378,6 @@ def current_humidity_received(msg: ReceiveMessage) -> None: ) return self._attr_current_humidity = current_humidity - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription( topics, CONF_CURRENT_HUMIDITY_TOPIC, current_humidity_received @@ -392,6 +385,7 @@ def current_humidity_received(msg: ReceiveMessage) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_humidity"}) def target_humidity_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the target humidity.""" rendered_target_humidity_payload = self._value_templates[ATTR_HUMIDITY]( @@ -402,7 +396,6 @@ def target_humidity_received(msg: ReceiveMessage) -> None: return if rendered_target_humidity_payload == self._payload["HUMIDITY_RESET"]: self._attr_target_humidity = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: target_humidity = round(float(rendered_target_humidity_payload)) @@ -426,7 +419,6 @@ def target_humidity_received(msg: ReceiveMessage) -> None: ) return self._attr_target_humidity = target_humidity - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription( topics, CONF_TARGET_HUMIDITY_STATE_TOPIC, target_humidity_received @@ -434,12 +426,12 @@ def target_humidity_received(msg: ReceiveMessage) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_mode"}) def mode_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for mode.""" mode = str(self._value_templates[ATTR_MODE](msg.payload)) if mode == self._payload["MODE_RESET"]: self._attr_mode = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if not mode: _LOGGER.debug("Ignoring empty mode from '%s'", msg.topic) @@ -454,7 +446,6 @@ def mode_received(msg: ReceiveMessage) -> None: return self._attr_mode = mode - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_MODE_STATE_TOPIC, mode_received) diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index fc3996ffbffb65..68c7eda16eaa3b 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -32,7 +32,12 @@ DEFAULT_RETAIN, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -40,7 +45,7 @@ ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -114,18 +119,6 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): _command_topics: dict[str, str] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT lawn mower.""" - self._attr_current_option = None - LawnMowerEntity.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -168,6 +161,7 @@ def _prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_activity"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" payload = str(self._value_template(msg.payload)) @@ -180,7 +174,6 @@ def message_received(msg: ReceiveMessage) -> None: return if payload.lower() == "none": self._attr_activity = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: @@ -193,7 +186,6 @@ def message_received(msg: ReceiveMessage) -> None: [option.value for option in LawnMowerActivity], ) return - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._config.get(CONF_ACTIVITY_STATE_TOPIC) is None: # Force into optimistic mode. diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index ab8d9921161de7..65c05501658e9f 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -264,16 +264,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): _optimistic_rgbww_color: bool _optimistic_xy_color: bool - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize MQTT light.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index ee7e78b00285c1..462280b1516697 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -184,21 +184,11 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED + _fixed_color_mode: ColorMode | str | None = None _flash_times: dict[str, int | None] _topic: dict[str, str | None] _optimistic: bool - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize MQTT JSON light.""" - self._fixed_color_mode: ColorMode | str | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index ecbcdcd18d792d..a225ce43efa41d 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -138,16 +138,6 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): _fixed_color_mode: ColorMode | str | None _topics: dict[str, str | None] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize a MQTT Template light.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index d2e67ba40da3b2..9a0ce2077f3d2c 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -28,12 +28,18 @@ CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_ENCODING, + CONF_PAYLOAD_RESET, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -41,7 +47,6 @@ ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data CONF_CODE_FORMAT = "code_format" @@ -59,6 +64,7 @@ DEFAULT_PAYLOAD_LOCK = "LOCK" DEFAULT_PAYLOAD_UNLOCK = "UNLOCK" DEFAULT_PAYLOAD_OPEN = "OPEN" +DEFAULT_PAYLOAD_RESET = "None" DEFAULT_STATE_LOCKED = "LOCKED" DEFAULT_STATE_LOCKING = "LOCKING" DEFAULT_STATE_UNLOCKED = "UNLOCKED" @@ -80,6 +86,7 @@ vol.Optional(CONF_PAYLOAD_LOCK, default=DEFAULT_PAYLOAD_LOCK): cv.string, vol.Optional(CONF_PAYLOAD_UNLOCK, default=DEFAULT_PAYLOAD_UNLOCK): cv.string, vol.Optional(CONF_PAYLOAD_OPEN): cv.string, + vol.Optional(CONF_PAYLOAD_RESET, default=DEFAULT_PAYLOAD_RESET): cv.string, vol.Optional(CONF_STATE_JAMMED, default=DEFAULT_STATE_JAMMED): cv.string, vol.Optional(CONF_STATE_LOCKED, default=DEFAULT_STATE_LOCKED): cv.string, vol.Optional(CONF_STATE_LOCKING, default=DEFAULT_STATE_LOCKING): cv.string, @@ -138,17 +145,6 @@ class MqttLock(MqttEntity, LockEntity): ] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the lock.""" - self._attr_is_locked = False - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -156,10 +152,13 @@ def config_schema() -> vol.Schema: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._optimistic = ( - config[CONF_OPTIMISTIC] or self._config.get(CONF_STATE_TOPIC) is None - ) - self._attr_assumed_state = bool(self._optimistic) + if ( + optimistic := config[CONF_OPTIMISTIC] + or config.get(CONF_STATE_TOPIC) is None + ): + self._attr_is_locked = False + self._optimistic = optimistic + self._attr_assumed_state = bool(optimistic) self._compiled_pattern = config.get(CONF_CODE_FORMAT) self._attr_code_format = ( @@ -190,17 +189,28 @@ def _prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_is_jammed", + "_attr_is_locked", + "_attr_is_locking", + "_attr_is_unlocking", + }, + ) def message_received(msg: ReceiveMessage) -> None: """Handle new lock state messages.""" - payload = self._value_template(msg.payload) - if payload in self._valid_states: + if (payload := self._value_template(msg.payload)) == self._config[ + CONF_PAYLOAD_RESET + ]: + # Reset the state to `unknown` + self._attr_is_locked = None + elif payload in self._valid_states: self._attr_is_locked = payload == self._config[CONF_STATE_LOCKED] self._attr_is_locking = payload == self._config[CONF_STATE_LOCKING] self._attr_is_unlocking = payload == self._config[CONF_STATE_UNLOCKING] self._attr_is_jammed = payload == self._config[CONF_STATE_JAMMED] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 8c599469ff2685..23faa726e09d46 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -11,8 +11,6 @@ import logging from typing import TYPE_CHECKING, Any, TypedDict -import attr - from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import template @@ -44,26 +42,26 @@ class PayloadSentinel(StrEnum): PublishPayloadType = str | bytes | int | float | None -@attr.s(slots=True, frozen=True) +@dataclass class PublishMessage: - """MQTT Message.""" + """MQTT Message for publishing.""" - topic: str = attr.ib() - payload: PublishPayloadType = attr.ib() - qos: int = attr.ib() - retain: bool = attr.ib() + topic: str + payload: PublishPayloadType + qos: int + retain: bool -@attr.s(slots=True, frozen=True) +@dataclass class ReceiveMessage: - """MQTT Message.""" - - topic: str = attr.ib() - payload: ReceivePayloadType = attr.ib() - qos: int = attr.ib() - retain: bool = attr.ib() - subscribed_topic: str = attr.ib(default=None) - timestamp: dt.datetime = attr.ib(default=None) + """MQTT Message received.""" + + topic: str + payload: ReceivePayloadType + qos: int + retain: bool + subscribed_topic: str + timestamp: dt.datetime AsyncMessageCallbackType = Callable[[ReceiveMessage], Coroutine[Any, Any, None]] diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index a88210a31980a2..231da95ffb0727 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -42,7 +42,12 @@ CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -50,7 +55,6 @@ ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -142,17 +146,6 @@ class MqttNumber(MqttEntity, RestoreNumber): _command_template: Callable[[PublishPayloadType], PublishPayloadType] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT Number.""" - RestoreNumber.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -183,6 +176,7 @@ def _prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_native_value"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" num_value: int | float | None @@ -214,7 +208,6 @@ def message_received(msg: ReceiveMessage) -> None: return self._attr_native_value = num_value - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index fd876976fe6c9f..9e7c280cbc08b3 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -69,16 +69,6 @@ class MqttScene( _default_name = DEFAULT_NAME _entity_id_format = scene.DOMAIN + ".{}" - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT scene.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 1c4b33de0ee9d1..03cd529fdd0dfd 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -28,7 +28,12 @@ CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -36,7 +41,6 @@ ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -89,6 +93,7 @@ async def _async_setup_entity( class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): """representation of an MQTT select.""" + _attr_current_option: str | None = None _default_name = DEFAULT_NAME _entity_id_format = select.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_SELECT_ATTRIBUTES_BLOCKED @@ -96,18 +101,6 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] _optimistic: bool = False - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT select.""" - self._attr_current_option = None - SelectEntity.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -131,12 +124,12 @@ def _prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_option"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" payload = str(self._value_template(msg.payload)) if payload.lower() == "none": self._attr_current_option = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if payload not in self.options: @@ -148,7 +141,6 @@ def message_received(msg: ReceiveMessage) -> None: ) return self._attr_current_option = payload - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 278e70a9737995..05db22a8e625f6 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -130,22 +130,12 @@ class MqttSensor(MqttEntity, RestoreSensor): _entity_id_format = ENTITY_ID_FORMAT _attr_last_reset: datetime | None = None _attributes_extra_blocked = MQTT_SENSOR_ATTRIBUTES_BLOCKED + _expiration_trigger: CALLBACK_TYPE | None = None _expire_after: int | None _expired: bool | None _template: Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] _last_reset_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the sensor.""" - self._expiration_trigger: CALLBACK_TYPE | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" last_state: State | None diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index aeabd0fe148007..7978776a089cd6 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -49,7 +49,12 @@ PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -57,7 +62,6 @@ ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data DEFAULT_NAME = "MQTT Siren" DEFAULT_PAYLOAD_ON = "ON" @@ -151,17 +155,6 @@ class MqttSiren(MqttEntity, SirenEntity): _state_off: str _optimistic: bool - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT siren.""" - self._extra_attributes: dict[str, Any] = {} - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -223,6 +216,7 @@ def _prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on", "_extra_attributes"}) def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" payload = self._value_template(msg.payload) @@ -278,8 +272,10 @@ def state_message_received(msg: ReceiveMessage) -> None: invalid_siren_parameters, ) return + # To be able to track changes to self._extra_attributes we assign + # a fresh copy to make the original tracked reference immutable. + self._extra_attributes = dict(self._extra_attributes) self._update(process_turn_on_params(self, params)) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. @@ -379,6 +375,7 @@ def _update(self, data: SirenTurnOnServiceParameters) -> None: """Update the extra siren state attributes.""" for attribute, support in SUPPORTED_ATTRIBUTES.items(): if self._attr_supported_features & support and attribute in data: - self._extra_attributes[attribute] = data[ - attribute # type: ignore[literal-required] - ] + data_attr = data[attribute] # type: ignore[literal-required] + if self._extra_attributes.get(attribute) == data_attr: + continue + self._extra_attributes[attribute] = data_attr diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index e8872d3f0d1e96..d4e8f2609d9eb4 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -37,9 +37,13 @@ PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data DEFAULT_NAME = "MQTT Switch" DEFAULT_PAYLOAD_ON = "ON" @@ -96,17 +100,6 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): _state_off: str _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT switch.""" - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -136,6 +129,7 @@ def _prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" payload = self._value_template(msg.payload) @@ -146,8 +140,6 @@ def state_message_received(msg: ReceiveMessage) -> None: elif payload == PAYLOAD_NONE: self._attr_is_on = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index 6d1196cfd957f9..630951f171eb9c 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -35,7 +35,12 @@ CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MessageCallbackType, MqttCommandTemplate, @@ -44,7 +49,6 @@ ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -124,6 +128,7 @@ async def _async_setup_entity( class MqttTextEntity(MqttEntity, TextEntity): """Representation of the MQTT text entity.""" + _attr_native_value: str | None = None _attributes_extra_blocked = MQTT_TEXT_ATTRIBUTES_BLOCKED _default_name = DEFAULT_NAME _entity_id_format = text.ENTITY_ID_FORMAT @@ -133,17 +138,6 @@ class MqttTextEntity(MqttEntity, TextEntity): _command_template: Callable[[PublishPayloadType], PublishPayloadType] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, - ) -> None: - """Initialize MQTT text entity.""" - self._attr_native_value = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -188,11 +182,11 @@ def add_subscription( @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_native_value"}) def handle_state_message_received(msg: ReceiveMessage) -> None: """Handle receiving state message via MQTT.""" payload = str(self._value_template(msg.payload)) self._attr_native_value = payload - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received) diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index f6db0d3fd64c40..45cca7279f9bfb 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -33,9 +33,14 @@ PAYLOAD_EMPTY_JSON, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -109,24 +114,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): _default_name = DEFAULT_NAME _entity_id_format = update.ENTITY_ID_FORMAT - - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, - ) -> None: - """Initialize the MQTT update.""" - self._config = config - self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) - self._attr_release_summary = self._config.get(CONF_RELEASE_SUMMARY) - self._attr_release_url = self._config.get(CONF_RELEASE_URL) - self._attr_title = self._config.get(CONF_TITLE) - self._entity_picture: str | None = self._config.get(CONF_ENTITY_PICTURE) - - UpdateEntity.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + _entity_picture: str | None @property def entity_picture(self) -> str | None: @@ -143,6 +131,11 @@ def config_schema() -> vol.Schema: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) + self._attr_release_summary = self._config.get(CONF_RELEASE_SUMMARY) + self._attr_release_url = self._config.get(CONF_RELEASE_URL) + self._attr_title = self._config.get(CONF_TITLE) + self._entity_picture: str | None = self._config.get(CONF_ENTITY_PICTURE) self._templates = { CONF_VALUE_TEMPLATE: MqttValueTemplate( config.get(CONF_VALUE_TEMPLATE), @@ -171,6 +164,17 @@ def add_subscription( @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_installed_version", + "_attr_latest_version", + "_attr_title", + "_attr_release_summary", + "_attr_release_url", + "_entity_picture", + }, + ) def handle_state_message_received(msg: ReceiveMessage) -> None: """Handle receiving state message via MQTT.""" payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) @@ -219,39 +223,33 @@ def handle_state_message_received(msg: ReceiveMessage) -> None: if "installed_version" in json_payload: self._attr_installed_version = json_payload["installed_version"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "latest_version" in json_payload: self._attr_latest_version = json_payload["latest_version"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "title" in json_payload: self._attr_title = json_payload["title"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "release_summary" in json_payload: self._attr_release_summary = json_payload["release_summary"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "release_url" in json_payload: self._attr_release_url = json_payload["release_url"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "entity_picture" in json_payload: self._entity_picture = json_payload["entity_picture"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_latest_version"}) def handle_latest_version_received(msg: ReceiveMessage) -> None: """Handle receiving latest version via MQTT.""" latest_version = self._templates[CONF_LATEST_VERSION_TEMPLATE](msg.payload) if isinstance(latest_version, str) and latest_version != "": self._attr_latest_version = latest_version - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscription( topics, CONF_LATEST_VERSION_TOPIC, handle_latest_version_received @@ -279,8 +277,6 @@ async def async_install( self._config[CONF_ENCODING], ) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - @property def supported_features(self) -> UpdateEntityFeature: """Return the list of supported features.""" diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index 516a7772c11172..aee71cc669044d 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -30,14 +30,14 @@ from ..config import MQTT_BASE_SCHEMA from ..const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ( MqttValueTemplate, PayloadSentinel, ReceiveMessage, ReceivePayloadType, ) -from ..util import get_mqtt_data, valid_publish_topic +from ..util import valid_publish_topic from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services @@ -215,12 +215,17 @@ async def async_setup_entity_legacy( class MqttVacuum(MqttEntity, VacuumEntity): """Representation of a MQTT-controlled legacy vacuum.""" - _default_name = DEFAULT_NAME - _entity_id_format = ENTITY_ID_FORMAT + _attr_battery_level = 0 + _attr_is_on = False _attributes_extra_blocked = MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED - + _charging: bool = False + _cleaning: bool = False _command_topic: str | None + _docked: bool = False + _default_name = DEFAULT_NAME + _entity_id_format = ENTITY_ID_FORMAT _encoding: str | None + _error: str | None = None _qos: bool _retain: bool _payloads: dict[str, str] @@ -231,25 +236,6 @@ class MqttVacuum(MqttEntity, VacuumEntity): str, Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] ] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the vacuum.""" - self._attr_battery_level = 0 - self._attr_is_on = False - self._attr_fan_speed = "unknown" - - self._charging = False - self._cleaning = False - self._docked = False - self._error: str | None = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -313,6 +299,20 @@ def _prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_battery_level", + "_attr_fan_speed", + "_attr_is_on", + # We track _attr_status and _charging as they are used to + # To determine the batery_icon. + # We do not need to track _docked as it is + # not leading to entity changes directly. + "_attr_status", + "_charging", + }, + ) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT message.""" if ( @@ -387,8 +387,6 @@ def message_received(msg: ReceiveMessage) -> None: if fan_speed and fan_speed is not PayloadSentinel.DEFAULT: self._attr_fan_speed = str(fan_speed) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - topics_list = {topic for topic in self._state_topics.values() if topic} self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 5113e19f097e10..425202adea208a 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -38,9 +38,9 @@ CONF_STATE_TOPIC, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ReceiveMessage -from ..util import get_mqtt_data, valid_publish_topic +from ..util import valid_publish_topic from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services @@ -231,6 +231,9 @@ def _prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, {"_attr_battery_level", "_attr_fan_speed", "_attr_state"} + ) def state_message_received(msg: ReceiveMessage) -> None: """Handle state MQTT message.""" payload = json_loads_object(msg.payload) @@ -242,7 +245,6 @@ def state_message_received(msg: ReceiveMessage) -> None: ) del payload[STATE] self._update_state_attributes(payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if state_topic := self._config.get(CONF_STATE_TOPIC): topics["state_position_topic"] = { diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index f35e7f8b0eaa24..9a9326d6d070ce 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -194,18 +194,6 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): _entity_id_format = water_heater.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the water heater device.""" - MqttTemperatureControlEntity.__init__( - self, hass, config, config_entry, discovery_data - ) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/myq/const.py b/homeassistant/components/myq/const.py index 3c7b5ba373adab..16dead344776a0 100644 --- a/homeassistant/components/myq/const.py +++ b/homeassistant/components/myq/const.py @@ -33,14 +33,4 @@ MYQ_GATEWAY = "myq_gateway" MYQ_COORDINATOR = "coordinator" -# myq has some ratelimits in place -# and 61 seemed to be work every time -UPDATE_INTERVAL = 15 - -# Estimated time it takes myq to start transition from one -# state to the next. -TRANSITION_START_DURATION = 7 - -# Estimated time it takes myq to complete a transition -# from one state to another -TRANSITION_COMPLETE_DURATION = 37 +UPDATE_INTERVAL = 30 diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 5e03f962d15270..02bf454bc3e85c 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -1,7 +1,7 @@ { "domain": "myq", "name": "MyQ", - "codeowners": ["@ehendrix23"], + "codeowners": ["@ehendrix23", "@Lash-L"], "config_flow": true, "dhcp": [ { @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["pkce", "pymyq"], - "requirements": ["pymyq==3.1.4"] + "requirements": ["python-myq==3.1.9"] } diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 3f405767c54947..02899dbc1a27f8 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -20,6 +20,11 @@ } +def extract_message(msg: Any) -> tuple[str, Any]: + """Extract the message content and the topic.""" + return msg.Topic._value_1, msg.Message._value_1 # pylint: disable=protected-access + + def _normalize_video_source(source: str) -> str: """Normalize video source. @@ -48,15 +53,15 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/MotionAlarm """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic}_{source}", "Motion Alarm", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -71,15 +76,15 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBlurry/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic}_{source}", "Image Too Blurry", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -95,15 +100,15 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooDark/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic}_{source}", "Image Too Dark", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -119,15 +124,15 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBright/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic}_{source}", "Image Too Bright", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -143,15 +148,15 @@ async def async_parse_scene_change(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/GlobalSceneChange/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic}_{source}", "Global Scene Change", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -167,8 +172,8 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: audio_source = "" audio_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "AudioSourceConfigurationToken": audio_source = source.Value if source.Name == "AudioAnalyticsConfigurationToken": @@ -177,12 +182,12 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{audio_source}_{audio_analytics}_{rule}", + f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}", "Detected Sound", "binary_sensor", "sound", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -198,8 +203,8 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -208,12 +213,12 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: rule = source.Value evt = Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Field Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) return evt except (AttributeError, KeyError): @@ -230,8 +235,8 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -240,12 +245,12 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Cell Motion Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -261,8 +266,8 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -271,12 +276,12 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Motion Region Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value in ["1", "true"], + payload.Data.SimpleItem[0].Value in ["1", "true"], ) except (AttributeError, KeyError): return None @@ -292,8 +297,8 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -302,12 +307,12 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Tamper Detection", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -322,18 +327,18 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic}_{video_source}", "Pet Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -347,18 +352,18 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic}_{video_source}", "Vehicle Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -372,18 +377,18 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic}_{video_source}", "Person Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -397,18 +402,18 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic}_{video_source}", "Face Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -422,18 +427,18 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic}_{video_source}", "Visitor Detection", "binary_sensor", "occupancy", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -446,15 +451,15 @@ async def async_parse_digital_input(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/DigitalInput """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic}_{source}", "Digital Input", "binary_sensor", None, None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -467,15 +472,15 @@ async def async_parse_relay(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/Relay """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic}_{source}", "Relay Triggered", "binary_sensor", None, None, - value_1.Data.SimpleItem[0].Value == "active", + payload.Data.SimpleItem[0].Value == "active", ) except (AttributeError, KeyError): return None @@ -488,15 +493,15 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None: Topic: tns1:Device/HardwareFailure/StorageFailure """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic}_{source}", "Storage Failure", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -510,13 +515,13 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/ProcessorUsage """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - usage = float(value_1.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + usage = float(payload.Data.SimpleItem[0].Value) if usage <= 1: usage *= 100 return Event( - f"{uid}_{value_1}", + f"{uid}_{topic}", "Processor Usage", "sensor", None, @@ -535,10 +540,10 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReboot """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic}", "Last Reboot", "sensor", "timestamp", @@ -557,10 +562,10 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReset """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic}", "Last Reset", "sensor", "timestamp", @@ -581,10 +586,10 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic}", "Last Backup", "sensor", "timestamp", @@ -604,10 +609,10 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic}", "Last Clock Synchronization", "sensor", "timestamp", @@ -628,15 +633,15 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic}_{source}", "Recording Job State", "binary_sensor", None, None, - value_1.Data.SimpleItem[0].Value == "Active", + payload.Data.SimpleItem[0].Value == "Active", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -653,8 +658,8 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = source.Value if source.Name == "VideoAnalyticsConfigurationToken": @@ -663,12 +668,12 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Line Detector Crossed", "sensor", None, None, - value_1.Data.SimpleItem[0].Value, + payload.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -685,8 +690,8 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -695,12 +700,12 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Count Aggregation Counter", "sensor", None, None, - value_1.Data.SimpleItem[0].Value, + payload.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 002495b951791c..71fd841d0fc725 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.34"] + "requirements": ["opower==0.0.35"] } diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index 56b7eaaac77c5a..644ecb8cf3d273 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -1,23 +1,15 @@ """The PoolSense integration.""" -import asyncio -from datetime import timedelta import logging from poolsense import PoolSense -from poolsense.exceptions import PoolSenseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from .const import ATTRIBUTION, DOMAIN +from .const import DOMAIN +from .coordinator import PoolSenseDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -57,44 +49,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class PoolSenseEntity(CoordinatorEntity): - """Implements a common class elements representing the PoolSense component.""" - - _attr_attribution = ATTRIBUTION - - def __init__(self, coordinator, email, description: EntityDescription) -> None: - """Initialize poolsense sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_name = f"PoolSense {description.name}" - self._attr_unique_id = f"{email}-{description.key}" - - -class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): - """Define an object to hold PoolSense data.""" - - def __init__(self, hass, entry): - """Initialize.""" - self.poolsense = PoolSense( - aiohttp_client.async_get_clientsession(hass), - entry.data[CONF_EMAIL], - entry.data[CONF_PASSWORD], - ) - self.hass = hass - self.entry = entry - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(hours=1)) - - async def _async_update_data(self): - """Update data via library.""" - data = {} - async with asyncio.timeout(10): - try: - data = await self.poolsense.get_poolsense_data() - except PoolSenseError as error: - _LOGGER.error("PoolSense query did not complete") - raise UpdateFailed(error) from error - - return data diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index e206521c3d9b8a..052a205a37b495 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PoolSenseEntity from .const import DOMAIN +from .entity import PoolSenseEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( @@ -48,6 +48,6 @@ class PoolSenseBinarySensor(PoolSenseEntity, BinarySensorEntity): """Representation of PoolSense binary sensors.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.coordinator.data[self.entity_description.key] == "red" diff --git a/homeassistant/components/poolsense/config_flow.py b/homeassistant/components/poolsense/config_flow.py index 6a6708b404555d..64685d67035875 100644 --- a/homeassistant/components/poolsense/config_flow.py +++ b/homeassistant/components/poolsense/config_flow.py @@ -1,11 +1,13 @@ """Config flow for PoolSense integration.""" import logging +from typing import Any from poolsense import PoolSense import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN @@ -21,7 +23,9 @@ class PoolSenseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize PoolSense config flow.""" - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} diff --git a/homeassistant/components/poolsense/coordinator.py b/homeassistant/components/poolsense/coordinator.py new file mode 100644 index 00000000000000..e5e3e6ad1bdad9 --- /dev/null +++ b/homeassistant/components/poolsense/coordinator.py @@ -0,0 +1,45 @@ +"""DataUpdateCoordinator for poolsense integration.""" +import asyncio +from datetime import timedelta +import logging + +from poolsense import PoolSense +from poolsense.exceptions import PoolSenseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, StateType]]): + """Define an object to hold PoolSense data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize.""" + self.poolsense = PoolSense( + aiohttp_client.async_get_clientsession(hass), + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + ) + self.hass = hass + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(hours=1)) + + async def _async_update_data(self) -> dict[str, StateType]: + """Update data via library.""" + data = {} + async with asyncio.timeout(10): + try: + data = await self.poolsense.get_poolsense_data() + except PoolSenseError as error: + _LOGGER.error("PoolSense query did not complete") + raise UpdateFailed(error) from error + + return data diff --git a/homeassistant/components/poolsense/entity.py b/homeassistant/components/poolsense/entity.py new file mode 100644 index 00000000000000..0eca39cc48d65e --- /dev/null +++ b/homeassistant/components/poolsense/entity.py @@ -0,0 +1,24 @@ +"""Base entity for poolsense integration.""" +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION +from .coordinator import PoolSenseDataUpdateCoordinator + + +class PoolSenseEntity(CoordinatorEntity[PoolSenseDataUpdateCoordinator]): + """Implements a common class elements representing the PoolSense component.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: PoolSenseDataUpdateCoordinator, + email: str, + description: EntityDescription, + ) -> None: + """Initialize poolsense sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_name = f"PoolSense {description.name}" + self._attr_unique_id = f"{email}-{description.key}" diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index fe3535b378f705..c61196d99318f6 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -15,9 +15,10 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from . import PoolSenseEntity from .const import DOMAIN +from .entity import PoolSenseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -28,8 +29,8 @@ ), SensorEntityDescription( key="pH", - translation_key="ph", icon="mdi:pool", + device_class=SensorDeviceClass.PH, ), SensorEntityDescription( key="Battery", @@ -93,6 +94,6 @@ class PoolSenseSensor(PoolSenseEntity, SensorEntity): """Sensor representing poolsense data.""" @property - def native_value(self): + def native_value(self) -> StateType: """State of the sensor.""" return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/poolsense/strings.json b/homeassistant/components/poolsense/strings.json index 9ec67e223a1002..02f186994e232c 100644 --- a/homeassistant/components/poolsense/strings.json +++ b/homeassistant/components/poolsense/strings.json @@ -28,9 +28,6 @@ "chlorine": { "name": "Chlorine" }, - "ph": { - "name": "pH" - }, "last_seen": { "name": "Last seen" }, diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 2af0cb30f1e89f..a97af14f44953f 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -10,10 +10,15 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_SERIAL_NUMBER -from .coordinator import RainbirdUpdateCoordinator +from .coordinator import RainbirdData -PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.NUMBER] +PLATFORMS = [ + Platform.SWITCH, + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.CALENDAR, +] DOMAIN = "rainbird" @@ -35,16 +40,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model_info = await controller.get_model_and_version() except RainbirdApiException as err: raise ConfigEntryNotReady from err - coordinator = RainbirdUpdateCoordinator( - hass, - name=entry.title, - controller=controller, - serial_number=entry.data[CONF_SERIAL_NUMBER], - model_info=model_info, - ) - await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + data = RainbirdData(hass, entry, controller, model_info) + await data.coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 139a17f5181969..b5886011ea3ed0 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird binary_sensor.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator async_add_entities([RainBirdSensor(coordinator, RAIN_SENSOR_ENTITY_DESCRIPTION)]) diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py new file mode 100644 index 00000000000000..4d8cc38c8bf846 --- /dev/null +++ b/homeassistant/components/rainbird/calendar.py @@ -0,0 +1,118 @@ +"""Rain Bird irrigation calendar.""" + +from __future__ import annotations + +from datetime import datetime +import logging + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import RainbirdScheduleUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entry for a Rain Bird irrigation calendar.""" + data = hass.data[DOMAIN][config_entry.entry_id] + if not data.model_info.model_info.max_programs: + return + + async_add_entities( + [ + RainBirdCalendarEntity( + data.schedule_coordinator, + data.coordinator.serial_number, + data.coordinator.device_info, + ) + ] + ) + + +class RainBirdCalendarEntity( + CoordinatorEntity[RainbirdScheduleUpdateCoordinator], CalendarEntity +): + """A calendar event entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_icon = "mdi:sprinkler" + + def __init__( + self, + coordinator: RainbirdScheduleUpdateCoordinator, + serial_number: str, + device_info: DeviceInfo, + ) -> None: + """Create the Calendar event device.""" + super().__init__(coordinator) + self._event: CalendarEvent | None = None + self._attr_unique_id = serial_number + self._attr_device_info = device_info + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + schedule = self.coordinator.data + if not schedule: + return None + cursor = schedule.timeline_tz(dt_util.DEFAULT_TIME_ZONE).active_after( + dt_util.now() + ) + program_event = next(cursor, None) + if not program_event: + return None + return CalendarEvent( + summary=program_event.program_id.name, + start=dt_util.as_local(program_event.start), + end=dt_util.as_local(program_event.end), + rrule=program_event.rrule_str, + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + schedule = self.coordinator.data + if not schedule: + raise HomeAssistantError( + "Unable to get events: No data from controller yet" + ) + cursor = schedule.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, + ) + return [ + CalendarEvent( + summary=program_event.program_id.name, + start=dt_util.as_local(program_event.start), + end=dt_util.as_local(program_event.end), + rrule=program_event.rrule_str, + ) + for program_event in cursor + ] + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # We do not ask for an update with async_add_entities() + # because it will update disabled entities. This is started as a + # task to let it sync in the background without blocking startup + self.coordinator.config_entry.async_create_background_task( + self.hass, + self.coordinator.async_request_refresh(), + "rainbird.calendar-refresh", + ) diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index cac86d8c928f1e..5c40ef808b20c2 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -5,6 +5,7 @@ import asyncio from dataclasses import dataclass import datetime +from functools import cached_property import logging from typing import TypeVar @@ -13,15 +14,19 @@ RainbirdApiException, RainbirdDeviceBusyException, ) -from pyrainbird.data import ModelAndVersion +from pyrainbird.data import ModelAndVersion, Schedule +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER, TIMEOUT_SECONDS UPDATE_INTERVAL = datetime.timedelta(minutes=1) +# The calendar data requires RPCs for each program/zone, and the data rarely +# changes, so we refresh it less often. +CALENDAR_UPDATE_INTERVAL = datetime.timedelta(minutes=15) _LOGGER = logging.getLogger(__name__) @@ -49,7 +54,7 @@ def __init__( serial_number: str, model_info: ModelAndVersion, ) -> None: - """Initialize ZoneStateUpdateCoordinator.""" + """Initialize RainbirdUpdateCoordinator.""" super().__init__( hass, _LOGGER, @@ -108,3 +113,66 @@ async def _fetch_data(self) -> RainbirdDeviceState: rain=rain, rain_delay=rain_delay, ) + + +class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]): + """Coordinator for rainbird irrigation schedule calls.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + name: str, + controller: AsyncRainbirdController, + ) -> None: + """Initialize ZoneStateUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=name, + update_method=self._async_update_data, + update_interval=CALENDAR_UPDATE_INTERVAL, + ) + self._controller = controller + + async def _async_update_data(self) -> Schedule: + """Fetch data from Rain Bird device.""" + try: + async with asyncio.timeout(TIMEOUT_SECONDS): + return await self._controller.get_schedule() + except RainbirdApiException as err: + raise UpdateFailed(f"Error communicating with Device: {err}") from err + + +@dataclass +class RainbirdData: + """Holder for shared integration data. + + The coordinators are lazy since they may only be used by some platforms when needed. + """ + + hass: HomeAssistant + entry: ConfigEntry + controller: AsyncRainbirdController + model_info: ModelAndVersion + + @cached_property + def coordinator(self) -> RainbirdUpdateCoordinator: + """Return RainbirdUpdateCoordinator.""" + return RainbirdUpdateCoordinator( + self.hass, + name=self.entry.title, + controller=self.controller, + serial_number=self.entry.data[CONF_SERIAL_NUMBER], + model_info=self.model_info, + ) + + @cached_property + def schedule_coordinator(self) -> RainbirdScheduleUpdateCoordinator: + """Return RainbirdScheduleUpdateCoordinator.""" + return RainbirdScheduleUpdateCoordinator( + self.hass, + name=f"{self.entry.title} Schedule", + controller=self.controller, + ) diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index de049f921dd704..d0945609a1bcf1 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -28,7 +28,7 @@ async def async_setup_entry( async_add_entities( [ RainDelayNumber( - hass.data[DOMAIN][config_entry.entry_id], + hass.data[DOMAIN][config_entry.entry_id].coordinator, ) ] ) diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index f5cf2390095ad2..32eb053f478449 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -32,7 +32,7 @@ async def async_setup_entry( async_add_entities( [ RainBirdSensor( - hass.data[DOMAIN][config_entry.entry_id], + hass.data[DOMAIN][config_entry.entry_id].coordinator, RAIN_DELAY_ENTITY_DESCRIPTION, ) ] diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 39bb4a7b0d1caa..cafc541d860c2d 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation switches.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator async_add_entities( RainBirdSwitch( coordinator, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 005859b865b5b1..24fb209ae0748c 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -24,6 +24,7 @@ from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( @@ -141,10 +142,39 @@ **{unit: VolumeConverter for unit in VolumeConverter.VALID_UNITS}, } +DATA_SHORT_TERM_STATISTICS_RUN_CACHE = "recorder_short_term_statistics_run_cache" + _LOGGER = logging.getLogger(__name__) +@dataclasses.dataclass(slots=True) +class ShortTermStatisticsRunCache: + """Cache for short term statistics runs.""" + + # This is a mapping of metadata_id:id of the last short term + # statistics run for each metadata_id + _latest_id_by_metadata_id: dict[int, int] = dataclasses.field(default_factory=dict) + + def get_latest_ids(self, metadata_ids: set[int]) -> dict[int, int]: + """Return the latest short term statistics ids for the metadata_ids.""" + return { + metadata_id: id_ + for metadata_id, id_ in self._latest_id_by_metadata_id.items() + if metadata_id in metadata_ids + } + + def set_latest_id_for_metadata_id(self, metadata_id: int, id_: int) -> None: + """Cache the latest id for the metadata_id.""" + self._latest_id_by_metadata_id[metadata_id] = id_ + + def set_latest_ids_for_metadata_ids( + self, metadata_id_to_id: dict[int, int] + ) -> None: + """Cache the latest id for the each metadata_id.""" + self._latest_id_by_metadata_id.update(metadata_id_to_id) + + class BaseStatisticsRow(TypedDict, total=False): """A processed row of statistic data.""" @@ -508,6 +538,8 @@ def _compile_statistics( platform_stats.extend(compiled.platform_stats) current_metadata.update(compiled.current_metadata) + new_short_term_stats: list[StatisticsBase] = [] + updated_metadata_ids: set[int] = set() # Insert collected statistics in the database for stats in platform_stats: modified_statistic_id, metadata_id = statistics_meta_manager.update_or_add( @@ -515,12 +547,14 @@ def _compile_statistics( ) if modified_statistic_id is not None: modified_statistic_ids.add(modified_statistic_id) - _insert_statistics( + updated_metadata_ids.add(metadata_id) + if new_stat := _insert_statistics( session, StatisticsShortTerm, metadata_id, stats["stat"], - ) + ): + new_short_term_stats.append(new_stat) if start.minute == 55: # A full hour is ready, summarize it @@ -533,6 +567,23 @@ def _compile_statistics( if start.minute == 55: instance.hass.bus.fire(EVENT_RECORDER_HOURLY_STATISTICS_GENERATED) + if updated_metadata_ids: + # These are always the newest statistics, so we can update + # the run cache without having to check the start_ts. + session.flush() # populate the ids of the new StatisticsShortTerm rows + run_cache = get_short_term_statistics_run_cache(instance.hass) + # metadata_id is typed to allow None, but we know it's not None here + # so we can safely cast it to int. + run_cache.set_latest_ids_for_metadata_ids( + cast( + dict[int, int], + { + new_stat.metadata_id: new_stat.id + for new_stat in new_short_term_stats + }, + ) + ) + return modified_statistic_ids @@ -566,16 +617,19 @@ def _insert_statistics( table: type[StatisticsBase], metadata_id: int, statistic: StatisticData, -) -> None: +) -> StatisticsBase | None: """Insert statistics in the database.""" try: - session.add(table.from_stats(metadata_id, statistic)) + stat = table.from_stats(metadata_id, statistic) + session.add(stat) + return stat except SQLAlchemyError: _LOGGER.exception( "Unexpected exception when inserting statistics %s:%s ", metadata_id, statistic, ) + return None def _update_statistics( @@ -1809,24 +1863,26 @@ def get_last_short_term_statistics( ) -def _latest_short_term_statistics_stmt( - metadata_ids: list[int], +def get_latest_short_term_statistics_by_ids( + session: Session, ids: Iterable[int] +) -> list[Row]: + """Return the latest short term statistics for a list of ids.""" + stmt = _latest_short_term_statistics_by_ids_stmt(ids) + return list( + cast( + Sequence[Row], + execute_stmt_lambda_element(session, stmt, orm_rows=False), + ) + ) + + +def _latest_short_term_statistics_by_ids_stmt( + ids: Iterable[int], ) -> StatementLambdaElement: - """Create the statement for finding the latest short term stat rows.""" + """Create the statement for finding the latest short term stat rows by id.""" return lambda_stmt( - lambda: select(*QUERY_STATISTICS_SHORT_TERM).join( - ( - most_recent_statistic_row := ( - select( - StatisticsShortTerm.metadata_id, - func.max(StatisticsShortTerm.start_ts).label("start_max"), - ) - .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) - .group_by(StatisticsShortTerm.metadata_id) - ).subquery() - ), - (StatisticsShortTerm.metadata_id == most_recent_statistic_row.c.metadata_id) - & (StatisticsShortTerm.start_ts == most_recent_statistic_row.c.start_max), + lambda: select(*QUERY_STATISTICS_SHORT_TERM).filter( + StatisticsShortTerm.id.in_(ids) ) ) @@ -1846,11 +1902,38 @@ def get_latest_short_term_statistics( ) if not metadata: return {} - metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) - stmt = _latest_short_term_statistics_stmt(metadata_ids) - stats = cast( - Sequence[Row], execute_stmt_lambda_element(session, stmt, orm_rows=False) + metadata_ids = set( + _extract_metadata_and_discard_impossible_columns(metadata, types) ) + run_cache = get_short_term_statistics_run_cache(hass) + # Try to find the latest short term statistics ids for the metadata_ids + # from the run cache first if we have it. If the run cache references + # a non-existent id because of a purge, we will detect it missing in the + # next step and run a query to re-populate the cache. + stats: list[Row] = [] + if metadata_id_to_id := run_cache.get_latest_ids(metadata_ids): + stats = get_latest_short_term_statistics_by_ids( + session, metadata_id_to_id.values() + ) + # If we are missing some metadata_ids in the run cache, we need run a query + # to populate the cache for each metadata_id, and then run another query + # to get the latest short term statistics for the missing metadata_ids. + if (missing_metadata_ids := metadata_ids - set(metadata_id_to_id)) and ( + found_latest_ids := { + latest_id + for metadata_id in missing_metadata_ids + if ( + latest_id := cache_latest_short_term_statistic_id_for_metadata_id( + run_cache, session, metadata_id + ) + ) + is not None + } + ): + stats.extend( + get_latest_short_term_statistics_by_ids(session, found_latest_ids) + ) + if not stats: return {} @@ -2221,9 +2304,77 @@ def _import_statistics_with_session( else: _insert_statistics(session, table, metadata_id, stat) + if table != StatisticsShortTerm: + return True + + # We just inserted new short term statistics, so we need to update the + # ShortTermStatisticsRunCache with the latest id for the metadata_id + run_cache = get_short_term_statistics_run_cache(instance.hass) + cache_latest_short_term_statistic_id_for_metadata_id( + run_cache, session, metadata_id + ) + return True +@singleton(DATA_SHORT_TERM_STATISTICS_RUN_CACHE) +def get_short_term_statistics_run_cache( + hass: HomeAssistant, +) -> ShortTermStatisticsRunCache: + """Get the short term statistics run cache.""" + return ShortTermStatisticsRunCache() + + +def cache_latest_short_term_statistic_id_for_metadata_id( + run_cache: ShortTermStatisticsRunCache, session: Session, metadata_id: int +) -> int | None: + """Cache the latest short term statistic for a given metadata_id. + + Returns the id of the latest short term statistic for the metadata_id + that was added to the cache, or None if no latest short term statistic + was found for the metadata_id. + """ + if latest := cast( + Sequence[Row], + execute_stmt_lambda_element( + session, + _find_latest_short_term_statistic_for_metadata_id_stmt(metadata_id), + orm_rows=False, + ), + ): + id_: int = latest[0].id + run_cache.set_latest_id_for_metadata_id(metadata_id, id_) + return id_ + return None + + +def _find_latest_short_term_statistic_for_metadata_id_stmt( + metadata_id: int, +) -> StatementLambdaElement: + """Create a statement to find the latest short term statistics for a metadata_id.""" + # + # This code only looks up one row, and should not be refactored to + # lookup multiple using func.max + # or similar, as that will cause the query to be significantly slower + # for DBMs such as PostgreSQL that will have to do a full scan + # + # For PostgreSQL a combined query plan looks like: + # (actual time=2.218..893.909 rows=170531 loops=1) + # + # For PostgreSQL a separate query plan looks like: + # (actual time=0.301..0.301 rows=1 loops=1) + # + # + return lambda_stmt( + lambda: select( + StatisticsShortTerm.id, + ) + .where(StatisticsShortTerm.metadata_id == metadata_id) + .order_by(StatisticsShortTerm.start_ts.desc()) + .limit(1) + ) + + @retryable_database_job("statistics") def import_statistics( instance: Recorder, diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 1007ee1d2dee05..dcf790748ecef6 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -16,10 +16,12 @@ CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, + SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType DOMAIN = "rest_command" @@ -58,6 +60,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the REST command component.""" + async def reload_service_handler(service: ServiceCall) -> None: + """Remove all rest_commands and load new ones from config.""" + conf = await async_integration_yaml_config(hass, DOMAIN) + + # conf will be None if the configuration can't be parsed + if conf is None: + return + + existing = hass.services.async_services().get(DOMAIN, {}) + for existing_service in existing: + if existing_service == SERVICE_RELOAD: + continue + hass.services.async_remove(DOMAIN, existing_service) + + for name, command_config in conf[DOMAIN].items(): + async_register_rest_command(name, command_config) + @callback def async_register_rest_command(name, command_config): """Create service for rest command.""" @@ -156,4 +175,8 @@ async def async_service_handler(service: ServiceCall) -> None: for name, command_config in config[DOMAIN].items(): async_register_rest_command(name, command_config) + hass.services.async_register( + DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) + ) + return True diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index f8869d75d4b30b..e522c29ce193c6 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -31,17 +31,10 @@ def __init__( def _get_data_from_coordinator(self) -> None: raise NotImplementedError - def _refresh_from_coordinator(self) -> None: + def _handle_coordinator_update(self) -> None: self._get_data_from_coordinator() self.async_write_ha_state() - # pylint: disable-next=hass-missing-super-call - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self._refresh_from_coordinator) - ) - @property def _risco(self): """Return the Risco API object.""" diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index b196723afbe0ea..1d60ea4d7c2767 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -86,15 +86,12 @@ def __init__( self._attr_name = f"Risco {self.coordinator.risco.site_name} {name} Events" self._attr_device_class = SensorDeviceClass.TIMESTAMP - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" + await super().async_added_to_hass() self._entity_registry = er.async_get(self.hass) - self.async_on_remove( - self.coordinator.async_add_listener(self._refresh_from_coordinator) - ) - def _refresh_from_coordinator(self): + def _handle_coordinator_update(self): events = self.coordinator.data for event in reversed(events): if event.category_id in self._excludes: diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index be75e3f4465fa0..a3f35b6555586d 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.35.1" + "async-upnp-client==0.36.1" ], "ssdp": [ { diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 298e1c1ca0010f..7276ec28323979 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -129,9 +129,12 @@ async def _async_migrate_entries( new_key, ) continue - assert device is not None and ( - device != "pump" or (device == "pump" and source_index is not None) - ) + if device == "pump" and source_index is None: + _LOGGER.debug( + "Unable to parse 'source_index' from existing unique_id for pump entity '%s'", + source_key, + ) + continue new_unique_id = ( f"{source_mac}_{generate_unique_id(device, source_index, new_key)}" ) @@ -152,7 +155,6 @@ async def _async_migrate_entries( updates["new_unique_id"] = new_unique_id if (old_name := migrations.get("old_name")) is not None: - assert old_name new_name = migrations["new_name"] if (s_old_name := slugify(old_name)) in entry.entity_id: new_entity_id = entry.entity_id.replace(s_old_name, slugify(new_name)) diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 337d308d8d9437..9192458dde4141 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -1,9 +1,12 @@ """Support for a ScreenLogic Binary Sensor.""" +from copy import copy from dataclasses import dataclass import logging -from screenlogicpy.const.common import DEVICE_TYPE, ON_OFF +from screenlogicpy.const.common import ON_OFF from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.binary_sensor import ( DOMAIN, @@ -12,85 +15,157 @@ BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator -from .data import ( - DEVICE_INCLUSION_RULES, - DEVICE_SUBSCRIPTION, - SupportedValueParameters, - build_base_entity_description, - iterate_expand_group_wildcard, - preprocess_supported_values, -) from .entity import ( ScreenlogicEntity, ScreenLogicEntityDescription, ScreenLogicPushEntity, ScreenLogicPushEntityDescription, ) -from .util import cleanup_excluded_entity, generate_unique_id +from .util import cleanup_excluded_entity _LOGGER = logging.getLogger(__name__) @dataclass -class SupportedBinarySensorValueParameters(SupportedValueParameters): - """Supported predefined data for a ScreenLogic binary sensor entity.""" - - device_class: BinarySensorDeviceClass | None = None - - -SUPPORTED_DATA: list[ - tuple[ScreenLogicDataPath, SupportedValueParameters] -] = preprocess_supported_values( - { - DEVICE.CONTROLLER: { - GROUP.SENSOR: { - VALUE.ACTIVE_ALERT: SupportedBinarySensorValueParameters(), - VALUE.CLEANER_DELAY: SupportedBinarySensorValueParameters(), - VALUE.FREEZE_MODE: SupportedBinarySensorValueParameters(), - VALUE.POOL_DELAY: SupportedBinarySensorValueParameters(), - VALUE.SPA_DELAY: SupportedBinarySensorValueParameters(), - }, - }, - DEVICE.PUMP: { - "*": { - VALUE.STATE: SupportedBinarySensorValueParameters(), - }, - }, - DEVICE.INTELLICHEM: { - GROUP.ALARM: { - VALUE.FLOW_ALARM: SupportedBinarySensorValueParameters(), - VALUE.ORP_HIGH_ALARM: SupportedBinarySensorValueParameters(), - VALUE.ORP_LOW_ALARM: SupportedBinarySensorValueParameters(), - VALUE.ORP_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), - VALUE.PH_HIGH_ALARM: SupportedBinarySensorValueParameters(), - VALUE.PH_LOW_ALARM: SupportedBinarySensorValueParameters(), - VALUE.PH_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), - VALUE.PROBE_FAULT_ALARM: SupportedBinarySensorValueParameters(), - }, - GROUP.ALERT: { - VALUE.ORP_LIMIT: SupportedBinarySensorValueParameters(), - VALUE.PH_LIMIT: SupportedBinarySensorValueParameters(), - VALUE.PH_LOCKOUT: SupportedBinarySensorValueParameters(), - }, - GROUP.WATER_BALANCE: { - VALUE.CORROSIVE: SupportedBinarySensorValueParameters(), - VALUE.SCALING: SupportedBinarySensorValueParameters(), - }, - }, - DEVICE.SCG: { - GROUP.SENSOR: { - VALUE.STATE: SupportedBinarySensorValueParameters(), - }, - }, - } -) +class ScreenLogicBinarySensorDescription( + BinarySensorEntityDescription, ScreenLogicEntityDescription +): + """A class that describes ScreenLogic binary sensor eneites.""" + + +@dataclass +class ScreenLogicPushBinarySensorDescription( + ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogicPushBinarySensor.""" -SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = {DEVICE_TYPE.ALARM: BinarySensorDeviceClass.PROBLEM} + +SUPPORTED_CORE_SENSORS = [ + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.ACTIVE_ALERT, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.CLEANER_DELAY, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.FREEZE_MODE, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.POOL_DELAY, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.SPA_DELAY, + ), +] + +SUPPORTED_PUMP_SENSORS = [ + ScreenLogicBinarySensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.STATE, + ) +] + +SUPPORTED_INTELLICHEM_SENSORS = [ + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.FLOW_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.ORP_HIGH_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.ORP_LOW_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.ORP_SUPPLY_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PH_HIGH_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PH_LOW_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PH_SUPPLY_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PROBE_FAULT_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), + key=VALUE.ORP_LIMIT, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), + key=VALUE.PH_LIMIT, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), + key=VALUE.PH_LOCKOUT, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.WATER_BALANCE), + key=VALUE.CORROSIVE, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.WATER_BALANCE), + key=VALUE.SCALING, + device_class=BinarySensorDeviceClass.PROBLEM, + ), +] + +SUPPORTED_SCG_SENSORS = [ + ScreenLogicBinarySensorDescription( + data_root=(DEVICE.SCG, GROUP.SENSOR), + key=VALUE.STATE, + ) +] async def async_setup_entry( @@ -104,72 +179,65 @@ async def async_setup_entry( config_entry.entry_id ] gateway = coordinator.gateway - data_path: ScreenLogicDataPath - value_params: SupportedBinarySensorValueParameters - for data_path, value_params in iterate_expand_group_wildcard( - gateway, SUPPORTED_DATA - ): - entity_key = generate_unique_id(*data_path) - - device = data_path[0] - - if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( - gateway, data_path - ): - cleanup_excluded_entity(coordinator, DOMAIN, entity_key) - continue - - try: - value_data = gateway.get_data(*data_path, strict=True) - except KeyError: - _LOGGER.debug("Failed to find %s", data_path) - continue - - entity_description_kwargs = { - **build_base_entity_description( - gateway, entity_key, data_path, value_data, value_params - ), - "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( - value_data.get(ATTR.DEVICE_TYPE) - ), - } + for core_sensor_description in SUPPORTED_CORE_SENSORS: if ( - sub_code := ( - value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) + gateway.get_data( + *core_sensor_description.data_root, core_sensor_description.key ) - ) is not None: + is not None + ): entities.append( - ScreenLogicPushBinarySensor( - coordinator, - ScreenLogicPushBinarySensorDescription( - subscription_code=sub_code, **entity_description_kwargs - ), - ) + ScreenLogicPushBinarySensor(coordinator, core_sensor_description) ) - else: + + for p_index, p_data in gateway.get_data(DEVICE.PUMP).items(): + if not p_data or not p_data.get(VALUE.DATA): + continue + for proto_pump_sensor_description in SUPPORTED_PUMP_SENSORS: entities.append( - ScreenLogicBinarySensor( - coordinator, - ScreenLogicBinarySensorDescription(**entity_description_kwargs), + ScreenLogicPumpBinarySensor( + coordinator, copy(proto_pump_sensor_description), p_index ) ) - async_add_entities(entities) + chem_sensor_description: ScreenLogicPushBinarySensorDescription + for chem_sensor_description in SUPPORTED_INTELLICHEM_SENSORS: + chem_sensor_data_path = ( + *chem_sensor_description.data_root, + chem_sensor_description.key, + ) + if EQUIPMENT_FLAG.INTELLICHEM not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, chem_sensor_data_path) + continue + if gateway.get_data(*chem_sensor_data_path): + entities.append( + ScreenLogicPushBinarySensor(coordinator, chem_sensor_description) + ) + scg_sensor_description: ScreenLogicBinarySensorDescription + for scg_sensor_description in SUPPORTED_SCG_SENSORS: + scg_sensor_data_path = ( + *scg_sensor_description.data_root, + scg_sensor_description.key, + ) + if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, scg_sensor_data_path) + continue + if gateway.get_data(*scg_sensor_data_path): + entities.append( + ScreenLogicBinarySensor(coordinator, scg_sensor_description) + ) -@dataclass -class ScreenLogicBinarySensorDescription( - BinarySensorEntityDescription, ScreenLogicEntityDescription -): - """A class that describes ScreenLogic binary sensor eneites.""" + async_add_entities(entities) class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): - """Base class for all ScreenLogic binary sensor entities.""" + """Representation of a ScreenLogic binary sensor entity.""" entity_description: ScreenLogicBinarySensorDescription _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC @property def is_on(self) -> bool: @@ -177,14 +245,21 @@ def is_on(self) -> bool: return self.entity_data[ATTR.VALUE] == ON_OFF.ON -@dataclass -class ScreenLogicPushBinarySensorDescription( - ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription -): - """Describes a ScreenLogicPushBinarySensor.""" - - class ScreenLogicPushBinarySensor(ScreenLogicPushEntity, ScreenLogicBinarySensor): - """Representation of a basic ScreenLogic sensor entity.""" + """Representation of a ScreenLogic push binary sensor entity.""" entity_description: ScreenLogicPushBinarySensorDescription + + +class ScreenLogicPumpBinarySensor(ScreenLogicBinarySensor): + """Representation of a ScreenLogic binary sensor entity for pump data.""" + + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicBinarySensorDescription, + pump_index: int, + ) -> None: + """Initialize of the entity.""" + entity_description.data_root = (DEVICE.PUMP, pump_index) + super().__init__(coordinator, entity_description) diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 889c8617274421..1d3f366a498f18 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -53,16 +53,14 @@ async def async_setup_entry( gateway = coordinator.gateway - for body_index, body_data in gateway.get_data(DEVICE.BODY).items(): - body_path = (DEVICE.BODY, body_index) + for body_index in gateway.get_data(DEVICE.BODY): entities.append( ScreenLogicClimate( coordinator, ScreenLogicClimateDescription( subscription_code=CODE.STATUS_CHANGED, - data_path=body_path, + data_root=(DEVICE.BODY,), key=body_index, - name=body_data[VALUE.HEAT_STATE][ATTR.NAME], ), ) ) @@ -99,6 +97,7 @@ def __init__(self, coordinator, entity_description) -> None: self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT] self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT] + self._attr_name = self.entity_data[VALUE.HEAT_STATE][ATTR.NAME] self._last_preset = None @property diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py index 5679b7e4dc99d3..719cebc1ef6c22 100644 --- a/homeassistant/components/screenlogic/data.py +++ b/homeassistant/components/screenlogic/data.py @@ -1,189 +1,5 @@ """Support for configurable supported data values for the ScreenLogic integration.""" -from collections.abc import Callable, Generator -from dataclasses import dataclass -from enum import StrEnum -from typing import Any - -from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const.data import ATTR, DEVICE, VALUE -from screenlogicpy.const.msg import CODE -from screenlogicpy.device_const.system import EQUIPMENT_FLAG - -from homeassistant.const import EntityCategory - -from .const import SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath - - -class PathPart(StrEnum): - """Placeholders for local data_path values.""" - - DEVICE = "!device" - KEY = "!key" - INDEX = "!index" - VALUE = "!sensor" - - -ScreenLogicDataPathTemplate = tuple[PathPart | str | int, ...] - - -class ScreenLogicRule: - """Represents a base default passing rule.""" - - def __init__( - self, test: Callable[..., bool] = lambda gateway, data_path: True - ) -> None: - """Initialize a ScreenLogic rule.""" - self._test = test - - def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: - """Method to check the rule.""" - return self._test(gateway, data_path) - - -class ScreenLogicDataRule(ScreenLogicRule): - """Represents a data rule.""" - - def __init__( - self, test: Callable[..., bool], test_path_template: tuple[PathPart, ...] - ) -> None: - """Initialize a ScreenLogic data rule.""" - self._test_path_template = test_path_template - super().__init__(test) - - def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: - """Check the rule against the gateway's data.""" - test_path = realize_path_template(self._test_path_template, data_path) - return self._test(gateway.get_data(*test_path)) - - -class ScreenLogicEquipmentRule(ScreenLogicRule): - """Represents an equipment flag rule.""" - - def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: - """Check the rule against the gateway's equipment flags.""" - return self._test(gateway.equipment_flags) - - -@dataclass -class SupportedValueParameters: - """Base supported values for ScreenLogic Entities.""" - - enabled: ScreenLogicRule = ScreenLogicRule() - included: ScreenLogicRule = ScreenLogicRule() - subscription_code: int | None = None - entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC - - -SupportedValueDescriptions = dict[str, SupportedValueParameters] - -SupportedGroupDescriptions = dict[int | str, SupportedValueDescriptions] - -SupportedDeviceDescriptions = dict[str, SupportedGroupDescriptions] - - -DEVICE_INCLUSION_RULES = { - DEVICE.PUMP: ScreenLogicDataRule( - lambda pump_data: pump_data[VALUE.DATA] != 0, - (PathPart.DEVICE, PathPart.INDEX), - ), - DEVICE.INTELLICHEM: ScreenLogicEquipmentRule( - lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags, - ), - DEVICE.SCG: ScreenLogicEquipmentRule( - lambda flags: EQUIPMENT_FLAG.CHLORINATOR in flags, - ), -} - -DEVICE_SUBSCRIPTION = { - DEVICE.CONTROLLER: CODE.STATUS_CHANGED, - DEVICE.INTELLICHEM: CODE.CHEMISTRY_CHANGED, -} - - -# not run-time -def get_ha_unit(entity_data: dict) -> StrEnum | str | None: - """Return a Home Assistant unit of measurement from a UNIT.""" - sl_unit = entity_data.get(ATTR.UNIT) - return SL_UNIT_TO_HA_UNIT.get(sl_unit, sl_unit) - - -# partial run-time -def realize_path_template( - template_path: ScreenLogicDataPathTemplate, data_path: ScreenLogicDataPath -) -> ScreenLogicDataPath: - """Create a new data path using a template and an existing data path. - - Construct new ScreenLogicDataPath from data_path using - template_path to specify values from data_path. - """ - if not data_path or len(data_path) < 3: - raise KeyError( - f"Missing or invalid required parameter: 'data_path' for template path '{template_path}'" - ) - device, group, data_key = data_path - realized_path: list[str | int] = [] - for part in template_path: - match part: - case PathPart.DEVICE: - realized_path.append(device) - case PathPart.INDEX | PathPart.KEY: - realized_path.append(group) - case PathPart.VALUE: - realized_path.append(data_key) - case _: - realized_path.append(part) - - return tuple(realized_path) - - -def preprocess_supported_values( - supported_devices: SupportedDeviceDescriptions, -) -> list[tuple[ScreenLogicDataPath, Any]]: - """Expand config dict into list of ScreenLogicDataPaths and settings.""" - processed: list[tuple[ScreenLogicDataPath, Any]] = [] - for device, device_groups in supported_devices.items(): - for group, group_values in device_groups.items(): - for value_key, value_params in group_values.items(): - value_data_path = (device, group, value_key) - processed.append((value_data_path, value_params)) - return processed - - -def iterate_expand_group_wildcard( - gateway: ScreenLogicGateway, - preprocessed_data: list[tuple[ScreenLogicDataPath, Any]], -) -> Generator[tuple[ScreenLogicDataPath, Any], None, None]: - """Iterate and expand any group wildcards to all available entries in gateway.""" - for data_path, value_params in preprocessed_data: - device, group, value_key = data_path - if group == "*": - for index in gateway.get_data(device): - yield ((device, index, value_key), value_params) - else: - yield (data_path, value_params) - - -def build_base_entity_description( - gateway: ScreenLogicGateway, - entity_key: str, - data_path: ScreenLogicDataPath, - value_data: dict, - value_params: SupportedValueParameters, -) -> dict: - """Build base entity description. - - Returns a dict of entity description key value pairs common to all entities. - """ - return { - "data_path": data_path, - "key": entity_key, - "entity_category": value_params.entity_category, - "entity_registry_enabled_default": value_params.enabled.test( - gateway, data_path - ), - "name": value_data.get(ATTR.NAME), - } - +from screenlogicpy.const.data import DEVICE, VALUE ENTITY_MIGRATIONS = { "chem_alarm": { diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index a29aaa9125b480..3b45aa699d3322 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -1,4 +1,5 @@ """Base ScreenLogicEntity definitions.""" +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime import logging @@ -18,15 +19,16 @@ from .const import ScreenLogicDataPath from .coordinator import ScreenlogicDataUpdateCoordinator +from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) @dataclass class ScreenLogicEntityRequiredKeyMixin: - """Mixin for required ScreenLogic entity key.""" + """Mixin for required ScreenLogic entity data_path.""" - data_path: ScreenLogicDataPath + data_root: ScreenLogicDataPath @dataclass @@ -35,6 +37,8 @@ class ScreenLogicEntityDescription( ): """Base class for a ScreenLogic entity description.""" + enabled_lambda: Callable[..., bool] | None = None + class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" @@ -50,10 +54,11 @@ def __init__( """Initialize of the entity.""" super().__init__(coordinator) self.entity_description = entity_description - self._data_path = self.entity_description.data_path - self._data_key = self._data_path[-1] - self._attr_unique_id = f"{self.mac}_{self.entity_description.key}" + self._data_key = self.entity_description.key + self._data_path = (*self.entity_description.data_root, self._data_key) mac = self.mac + self._attr_unique_id = f"{mac}_{generate_unique_id(*self._data_path)}" + self._attr_name = self.entity_data[ATTR.NAME] assert mac is not None self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, mac)}, @@ -88,9 +93,10 @@ async def _async_refresh_timed(self, now: datetime) -> None: @property def entity_data(self) -> dict: """Shortcut to the data for this entity.""" - if (data := self.gateway.get_data(*self._data_path)) is None: - raise KeyError(f"Data not found: {self._data_path}") - return data + try: + return self.gateway.get_data(*self._data_path, strict=True) + except KeyError as ke: + raise HomeAssistantError(f"Data not found: {self._data_path}") from ke @dataclass @@ -120,6 +126,7 @@ def __init__( ) -> None: """Initialize of the entity.""" super().__init__(coordinator, entity_description) + self._subscription_code = entity_description.subscription_code self._last_update_success = True @callback @@ -134,7 +141,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( await self.gateway.async_subscribe_client( self._async_data_updated, - self.entity_description.subscription_code, + self._subscription_code, ) ) diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py index 3875e34fbaabc2..80499f7790a505 100644 --- a/homeassistant/components/screenlogic/light.py +++ b/homeassistant/components/screenlogic/light.py @@ -34,7 +34,11 @@ async def async_setup_entry( ] gateway = coordinator.gateway for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): - if circuit_data[ATTR.FUNCTION] not in LIGHT_CIRCUIT_FUNCTIONS: + if ( + not circuit_data + or ((circuit_function := circuit_data.get(ATTR.FUNCTION)) is None) + or circuit_function not in LIGHT_CIRCUIT_FUNCTIONS + ): continue circuit_name = circuit_data[ATTR.NAME] circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) @@ -43,9 +47,8 @@ async def async_setup_entry( coordinator, ScreenLogicLightDescription( subscription_code=CODE.STATUS_CHANGED, - data_path=(DEVICE.CIRCUIT, circuit_index), + data_root=(DEVICE.CIRCUIT,), key=circuit_index, - name=circuit_name, entity_registry_enabled_default=( circuit_name not in GENERIC_CIRCUIT_NAMES and circuit_interface != INTERFACE.DONT_SHOW diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index 22805ffc3c1470..d3ed25f5570f9e 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -4,10 +4,10 @@ import logging from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.number import ( DOMAIN, - NumberDeviceClass, NumberEntity, NumberEntityDescription, ) @@ -16,20 +16,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator -from .data import ( - DEVICE_INCLUSION_RULES, - PathPart, - SupportedValueParameters, - build_base_entity_description, - get_ha_unit, - iterate_expand_group_wildcard, - preprocess_supported_values, - realize_path_template, -) from .entity import ScreenlogicEntity, ScreenLogicEntityDescription -from .util import cleanup_excluded_entity, generate_unique_id +from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) @@ -37,47 +27,44 @@ @dataclass -class SupportedNumberValueParametersMixin: - """Mixin for supported predefined data for a ScreenLogic number entity.""" +class ScreenLogicNumberRequiredMixin: + """Describes a required mixin for a ScreenLogic number entity.""" - set_value_config: tuple[str, tuple[tuple[PathPart | str | int, ...], ...]] - device_class: NumberDeviceClass | None = None + set_value_name: str + set_value_args: tuple[tuple[str | int, ...], ...] @dataclass -class SupportedNumberValueParameters( - SupportedValueParameters, SupportedNumberValueParametersMixin +class ScreenLogicNumberDescription( + NumberEntityDescription, + ScreenLogicEntityDescription, + ScreenLogicNumberRequiredMixin, ): - """Supported predefined data for a ScreenLogic number entity.""" + """Describes a ScreenLogic number entity.""" -SET_SCG_CONFIG_FUNC_DATA = ( - "async_set_scg_config", - ( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), +SUPPORTED_SCG_NUMBERS = [ + ScreenLogicNumberDescription( + set_value_name="async_set_scg_config", + set_value_args=( + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), + ), + data_root=(DEVICE.SCG, GROUP.CONFIGURATION), + key=VALUE.POOL_SETPOINT, + entity_category=EntityCategory.CONFIG, ), -) - - -SUPPORTED_DATA: list[ - tuple[ScreenLogicDataPath, SupportedValueParameters] -] = preprocess_supported_values( - { - DEVICE.SCG: { - GROUP.CONFIGURATION: { - VALUE.POOL_SETPOINT: SupportedNumberValueParameters( - entity_category=EntityCategory.CONFIG, - set_value_config=SET_SCG_CONFIG_FUNC_DATA, - ), - VALUE.SPA_SETPOINT: SupportedNumberValueParameters( - entity_category=EntityCategory.CONFIG, - set_value_config=SET_SCG_CONFIG_FUNC_DATA, - ), - } - } - } -) + ScreenLogicNumberDescription( + set_value_name="async_set_scg_config", + set_value_args=( + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), + ), + data_root=(DEVICE.SCG, GROUP.CONFIGURATION), + key=VALUE.SPA_SETPOINT, + entity_category=EntityCategory.CONFIG, + ), +] async def async_setup_entry( @@ -91,70 +78,21 @@ async def async_setup_entry( config_entry.entry_id ] gateway = coordinator.gateway - data_path: ScreenLogicDataPath - value_params: SupportedNumberValueParameters - for data_path, value_params in iterate_expand_group_wildcard( - gateway, SUPPORTED_DATA - ): - entity_key = generate_unique_id(*data_path) - - device = data_path[0] - - if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( - gateway, data_path - ): - cleanup_excluded_entity(coordinator, DOMAIN, entity_key) - continue - try: - value_data = gateway.get_data(*data_path, strict=True) - except KeyError: - _LOGGER.debug("Failed to find %s", data_path) - continue - - set_value_str, set_value_params = value_params.set_value_config - set_value_func = getattr(gateway, set_value_str) - - entity_description_kwargs = { - **build_base_entity_description( - gateway, entity_key, data_path, value_data, value_params - ), - "device_class": value_params.device_class, - "native_unit_of_measurement": get_ha_unit(value_data), - "native_max_value": value_data.get(ATTR.MAX_SETPOINT), - "native_min_value": value_data.get(ATTR.MIN_SETPOINT), - "native_step": value_data.get(ATTR.STEP), - "set_value": set_value_func, - "set_value_params": set_value_params, - } - - entities.append( - ScreenLogicNumber( - coordinator, - ScreenLogicNumberDescription(**entity_description_kwargs), - ) + for scg_number_description in SUPPORTED_SCG_NUMBERS: + scg_number_data_path = ( + *scg_number_description.data_root, + scg_number_description.key, ) + if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, scg_number_data_path) + continue + if gateway.get_data(*scg_number_data_path): + entities.append(ScreenLogicNumber(coordinator, scg_number_description)) async_add_entities(entities) -@dataclass -class ScreenLogicNumberRequiredMixin: - """Describes a required mixin for a ScreenLogic number entity.""" - - set_value: Callable[..., bool] - set_value_params: tuple[tuple[str | int, ...], ...] - - -@dataclass -class ScreenLogicNumberDescription( - NumberEntityDescription, - ScreenLogicEntityDescription, - ScreenLogicNumberRequiredMixin, -): - """Describes a ScreenLogic number entity.""" - - class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): """Class to represent a ScreenLogic Number entity.""" @@ -166,9 +104,30 @@ def __init__( entity_description: ScreenLogicNumberDescription, ) -> None: """Initialize a ScreenLogic number entity.""" - self._set_value_func = entity_description.set_value - self._set_value_params = entity_description.set_value_params super().__init__(coordinator, entity_description) + if not callable( + func := getattr(self.gateway, entity_description.set_value_name) + ): + raise TypeError( + f"set_value_name '{entity_description.set_value_name}' is not a callable" + ) + self._set_value_func: Callable[..., bool] = func + self._set_value_args = entity_description.set_value_args + self._attr_native_unit_of_measurement = get_ha_unit( + self.entity_data.get(ATTR.UNIT) + ) + if entity_description.native_max_value is None and isinstance( + max_val := self.entity_data.get(ATTR.MAX_SETPOINT), int | float + ): + self._attr_native_max_value = max_val + if entity_description.native_min_value is None and isinstance( + min_val := self.entity_data.get(ATTR.MIN_SETPOINT), int | float + ): + self._attr_native_min_value = min_val + if entity_description.native_step is None and isinstance( + step := self.entity_data.get(ATTR.STEP), int | float + ): + self._attr_native_step = step @property def native_value(self) -> float: @@ -182,12 +141,9 @@ async def async_set_native_value(self, value: float) -> None: # gathers the existing values and updates the particular value being # set by this entity. args = {} - for data_path in self._set_value_params: - data_path = realize_path_template(data_path, self._data_path) - data_value = data_path[-1] - args[data_value] = self.coordinator.gateway.get_value( - *data_path, strict=True - ) + for data_path in self._set_value_args: + data_key = data_path[-1] + args[data_key] = self.coordinator.gateway.get_value(*data_path, strict=True) args[self._data_key] = value diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 39805173961358..bbcf8458014577 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -1,10 +1,11 @@ """Support for a ScreenLogic Sensor.""" from collections.abc import Callable +from copy import copy from dataclasses import dataclass import logging -from screenlogicpy.const.common import DEVICE_TYPE, STATE_TYPE from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.chemistry import DOSE_STATE from screenlogicpy.device_const.pump import PUMP_TYPE from screenlogicpy.device_const.system import EQUIPMENT_FLAG @@ -17,145 +18,211 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator -from .data import ( - DEVICE_INCLUSION_RULES, - DEVICE_SUBSCRIPTION, - PathPart, - ScreenLogicDataRule, - ScreenLogicEquipmentRule, - SupportedValueParameters, - build_base_entity_description, - get_ha_unit, - iterate_expand_group_wildcard, - preprocess_supported_values, -) from .entity import ( ScreenlogicEntity, ScreenLogicEntityDescription, ScreenLogicPushEntity, ScreenLogicPushEntityDescription, ) -from .util import cleanup_excluded_entity, generate_unique_id +from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) @dataclass -class SupportedSensorValueParameters(SupportedValueParameters): - """Supported predefined data for a ScreenLogic sensor entity.""" - - device_class: SensorDeviceClass | None = None - value_modification: Callable[[int], int | str] | None = lambda val: val - - -SUPPORTED_DATA: list[ - tuple[ScreenLogicDataPath, SupportedValueParameters] -] = preprocess_supported_values( - { - DEVICE.CONTROLLER: { - GROUP.SENSOR: { - VALUE.AIR_TEMPERATURE: SupportedSensorValueParameters( - device_class=SensorDeviceClass.TEMPERATURE, entity_category=None - ), - VALUE.ORP: SupportedSensorValueParameters( - included=ScreenLogicEquipmentRule( - lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags - ) - ), - VALUE.PH: SupportedSensorValueParameters( - included=ScreenLogicEquipmentRule( - lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags - ) - ), - }, - }, - DEVICE.PUMP: { - "*": { - VALUE.WATTS_NOW: SupportedSensorValueParameters(), - VALUE.GPM_NOW: SupportedSensorValueParameters( - enabled=ScreenLogicDataRule( - lambda pump_data: pump_data[VALUE.TYPE] - != PUMP_TYPE.INTELLIFLO_VS, - (PathPart.DEVICE, PathPart.INDEX), - ) - ), - VALUE.RPM_NOW: SupportedSensorValueParameters( - enabled=ScreenLogicDataRule( - lambda pump_data: pump_data[VALUE.TYPE] - != PUMP_TYPE.INTELLIFLO_VF, - (PathPart.DEVICE, PathPart.INDEX), - ) - ), - }, - }, - DEVICE.INTELLICHEM: { - GROUP.SENSOR: { - VALUE.ORP_NOW: SupportedSensorValueParameters(), - VALUE.ORP_SUPPLY_LEVEL: SupportedSensorValueParameters( - value_modification=lambda val: val - 1 - ), - VALUE.PH_NOW: SupportedSensorValueParameters(), - VALUE.PH_PROBE_WATER_TEMP: SupportedSensorValueParameters(), - VALUE.PH_SUPPLY_LEVEL: SupportedSensorValueParameters( - value_modification=lambda val: val - 1 - ), - VALUE.SATURATION: SupportedSensorValueParameters(), - }, - GROUP.CONFIGURATION: { - VALUE.CALCIUM_HARNESS: SupportedSensorValueParameters(), - VALUE.CYA: SupportedSensorValueParameters(), - VALUE.ORP_SETPOINT: SupportedSensorValueParameters(), - VALUE.PH_SETPOINT: SupportedSensorValueParameters(), - VALUE.SALT_TDS_PPM: SupportedSensorValueParameters( - included=ScreenLogicEquipmentRule( - lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags - and EQUIPMENT_FLAG.CHLORINATOR not in flags, - ) - ), - VALUE.TOTAL_ALKALINITY: SupportedSensorValueParameters(), - }, - GROUP.DOSE_STATUS: { - VALUE.ORP_DOSING_STATE: SupportedSensorValueParameters( - value_modification=lambda val: DOSE_STATE(val).title, - ), - VALUE.ORP_LAST_DOSE_TIME: SupportedSensorValueParameters(), - VALUE.ORP_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), - VALUE.PH_DOSING_STATE: SupportedSensorValueParameters( - value_modification=lambda val: DOSE_STATE(val).title, - ), - VALUE.PH_LAST_DOSE_TIME: SupportedSensorValueParameters(), - VALUE.PH_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), - }, - }, - DEVICE.SCG: { - GROUP.SENSOR: { - VALUE.SALT_PPM: SupportedSensorValueParameters(), - }, - GROUP.CONFIGURATION: { - VALUE.SUPER_CHLOR_TIMER: SupportedSensorValueParameters(), - }, - }, - } -) +class ScreenLogicSensorMixin: + """Mixin for SecreenLogic sensor entity.""" + + value_mod: Callable[[int | str], int | str] | None = None + + +@dataclass +class ScreenLogicSensorDescription( + ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription +): + """Describes a ScreenLogic sensor.""" + + +@dataclass +class ScreenLogicPushSensorDescription( + ScreenLogicSensorDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic push sensor.""" -SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = { - DEVICE_TYPE.DURATION: SensorDeviceClass.DURATION, - DEVICE_TYPE.ENUM: SensorDeviceClass.ENUM, - DEVICE_TYPE.ENERGY: SensorDeviceClass.POWER, - DEVICE_TYPE.POWER: SensorDeviceClass.POWER, - DEVICE_TYPE.TEMPERATURE: SensorDeviceClass.TEMPERATURE, - DEVICE_TYPE.VOLUME: SensorDeviceClass.VOLUME, -} -SL_STATE_TYPE_TO_HA_STATE_CLASS = { - STATE_TYPE.MEASUREMENT: SensorStateClass.MEASUREMENT, - STATE_TYPE.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, -} +SUPPORTED_CORE_SENSORS = [ + ScreenLogicPushSensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.AIR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), +] + +SUPPORTED_PUMP_SENSORS = [ + ScreenLogicSensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.WATTS_NOW, + device_class=SensorDeviceClass.POWER, + ), + ScreenLogicSensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.GPM_NOW, + enabled_lambda=lambda type: type != PUMP_TYPE.INTELLIFLO_VS, + ), + ScreenLogicSensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.RPM_NOW, + enabled_lambda=lambda type: type != PUMP_TYPE.INTELLIFLO_VF, + ), +] + +SUPPORTED_INTELLICHEM_SENSORS = [ + ScreenLogicPushSensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.ORP, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.PH, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.ORP_NOW, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.PH_NOW, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.ORP_SUPPLY_LEVEL, + state_class=SensorStateClass.MEASUREMENT, + value_mod=lambda val: int(val) - 1, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.PH_SUPPLY_LEVEL, + state_class=SensorStateClass.MEASUREMENT, + value_mod=lambda val: int(val) - 1, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.PH_PROBE_WATER_TEMP, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.SATURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.CALCIUM_HARNESS, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.CYA, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.ORP_SETPOINT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.PH_SETPOINT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.TOTAL_ALKALINITY, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.SALT_TDS_PPM, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.ORP_DOSING_STATE, + device_class=SensorDeviceClass.ENUM, + options=["Dosing", "Mixing", "Monitoring"], + value_mod=lambda val: DOSE_STATE(val).title, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.ORP_LAST_DOSE_TIME, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.ORP_LAST_DOSE_VOLUME, + device_class=SensorDeviceClass.VOLUME, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.PH_DOSING_STATE, + device_class=SensorDeviceClass.ENUM, + options=["Dosing", "Mixing", "Monitoring"], + value_mod=lambda val: DOSE_STATE(val).title, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.PH_LAST_DOSE_TIME, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.PH_LAST_DOSE_VOLUME, + device_class=SensorDeviceClass.VOLUME, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +] + +SUPPORTED_SCG_SENSORS = [ + ScreenLogicSensorDescription( + data_root=(DEVICE.SCG, GROUP.SENSOR), + key=VALUE.SALT_PPM, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicSensorDescription( + data_root=(DEVICE.SCG, GROUP.CONFIGURATION), + key=VALUE.SUPER_CHLOR_TIMER, + ), +] async def async_setup_entry( @@ -169,81 +236,59 @@ async def async_setup_entry( config_entry.entry_id ] gateway = coordinator.gateway - data_path: ScreenLogicDataPath - value_params: SupportedSensorValueParameters - for data_path, value_params in iterate_expand_group_wildcard( - gateway, SUPPORTED_DATA - ): - entity_key = generate_unique_id(*data_path) - - device = data_path[0] - if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( - gateway, data_path + for core_sensor_description in SUPPORTED_CORE_SENSORS: + if ( + gateway.get_data( + *core_sensor_description.data_root, core_sensor_description.key + ) + is not None ): - cleanup_excluded_entity(coordinator, DOMAIN, entity_key) - continue + entities.append(ScreenLogicPushSensor(coordinator, core_sensor_description)) - try: - value_data = gateway.get_data(*data_path, strict=True) - except KeyError: - _LOGGER.debug("Failed to find %s", data_path) + for pump_index, pump_data in gateway.get_data(DEVICE.PUMP).items(): + if not pump_data or not pump_data.get(VALUE.DATA): continue - - entity_description_kwargs = { - **build_base_entity_description( - gateway, entity_key, data_path, value_data, value_params - ), - "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( - value_data.get(ATTR.DEVICE_TYPE) - ), - "native_unit_of_measurement": get_ha_unit(value_data), - "options": value_data.get(ATTR.ENUM_OPTIONS), - "state_class": SL_STATE_TYPE_TO_HA_STATE_CLASS.get( - value_data.get(ATTR.STATE_TYPE) - ), - "value_mod": value_params.value_modification, - } - - if ( - sub_code := ( - value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) - ) - ) is not None: - entities.append( - ScreenLogicPushSensor( - coordinator, - ScreenLogicPushSensorDescription( - subscription_code=sub_code, - **entity_description_kwargs, - ), - ) - ) - else: + pump_type = pump_data[VALUE.TYPE] + for proto_pump_sensor_description in SUPPORTED_PUMP_SENSORS: + if not pump_data.get(proto_pump_sensor_description.key): + continue entities.append( - ScreenLogicSensor( + ScreenLogicPumpSensor( coordinator, - ScreenLogicSensorDescription( - **entity_description_kwargs, - ), + copy(proto_pump_sensor_description), + pump_index, + pump_type, ) ) - async_add_entities(entities) - - -@dataclass -class ScreenLogicSensorMixin: - """Mixin for SecreenLogic sensor entity.""" - - value_mod: Callable[[int | str], int | str] | None = None - + chem_sensor_description: ScreenLogicPushSensorDescription + for chem_sensor_description in SUPPORTED_INTELLICHEM_SENSORS: + chem_sensor_data_path = ( + *chem_sensor_description.data_root, + chem_sensor_description.key, + ) + if EQUIPMENT_FLAG.INTELLICHEM not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, chem_sensor_data_path) + continue + if gateway.get_data(*chem_sensor_data_path): + chem_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + entities.append(ScreenLogicPushSensor(coordinator, chem_sensor_description)) + + scg_sensor_description: ScreenLogicSensorDescription + for scg_sensor_description in SUPPORTED_SCG_SENSORS: + scg_sensor_data_path = ( + *scg_sensor_description.data_root, + scg_sensor_description.key, + ) + if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, scg_sensor_data_path) + continue + if gateway.get_data(*scg_sensor_data_path): + scg_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + entities.append(ScreenLogicSensor(coordinator, scg_sensor_description)) -@dataclass -class ScreenLogicSensorDescription( - ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription -): - """Describes a ScreenLogic sensor.""" + async_add_entities(entities) class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): @@ -252,6 +297,17 @@ class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): entity_description: ScreenLogicSensorDescription _attr_has_entity_name = True + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicSensorDescription, + ) -> None: + """Initialize of the entity.""" + super().__init__(coordinator, entity_description) + self._attr_native_unit_of_measurement = get_ha_unit( + self.entity_data.get(ATTR.UNIT) + ) + @property def native_value(self) -> str | int | float: """State of the sensor.""" @@ -260,14 +316,29 @@ def native_value(self) -> str | int | float: return value_mod(val) if value_mod else val -@dataclass -class ScreenLogicPushSensorDescription( - ScreenLogicSensorDescription, ScreenLogicPushEntityDescription -): - """Describes a ScreenLogic push sensor.""" - - class ScreenLogicPushSensor(ScreenLogicSensor, ScreenLogicPushEntity): """Representation of a ScreenLogic push sensor entity.""" entity_description: ScreenLogicPushSensorDescription + + +class ScreenLogicPumpSensor(ScreenLogicSensor): + """Representation of a ScreenLogic pump sensor.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicSensorDescription, + pump_index: int, + pump_type: int, + ) -> None: + """Initialize of the entity.""" + entity_description.data_root = (DEVICE.PUMP, pump_index) + super().__init__(coordinator, entity_description) + if entity_description.enabled_lambda: + self._attr_entity_registry_enabled_default = ( + entity_description.enabled_lambda(pump_type) + ) diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 247ec4f2f03419..4900ed938a1559 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -30,7 +30,11 @@ async def async_setup_entry( ] gateway = coordinator.gateway for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): - if circuit_data[ATTR.FUNCTION] in LIGHT_CIRCUIT_FUNCTIONS: + if ( + not circuit_data + or ((circuit_function := circuit_data.get(ATTR.FUNCTION)) is None) + or circuit_function in LIGHT_CIRCUIT_FUNCTIONS + ): continue circuit_name = circuit_data[ATTR.NAME] circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) @@ -39,9 +43,8 @@ async def async_setup_entry( coordinator, ScreenLogicSwitchDescription( subscription_code=CODE.STATUS_CHANGED, - data_path=(DEVICE.CIRCUIT, circuit_index), + data_root=(DEVICE.CIRCUIT,), key=circuit_index, - name=circuit_name, entity_registry_enabled_default=( circuit_name not in GENERIC_CIRCUIT_NAMES and circuit_interface != INTERFACE.DONT_SHOW diff --git a/homeassistant/components/screenlogic/util.py b/homeassistant/components/screenlogic/util.py index c8d9d5f0f771f9..928effc73fc10d 100644 --- a/homeassistant/components/screenlogic/util.py +++ b/homeassistant/components/screenlogic/util.py @@ -5,32 +5,40 @@ from homeassistant.helpers import entity_registry as er -from .const import DOMAIN as SL_DOMAIN +from .const import DOMAIN as SL_DOMAIN, SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -def generate_unique_id( - device: str | int, group: str | int | None, data_key: str | int -) -> str: +def generate_unique_id(*args: str | int | None) -> str: """Generate new unique_id for a screenlogic entity from specified parameters.""" - if data_key in SHARED_VALUES and device is not None: - if group is not None and (isinstance(group, int) or group.isdigit()): - return f"{device}_{group}_{data_key}" - return f"{device}_{data_key}" - return str(data_key) + _LOGGER.debug("gen_uid called with %s", args) + if len(args) == 3: + if args[2] in SHARED_VALUES: + if args[1] is not None and (isinstance(args[1], int) or args[1].isdigit()): + return f"{args[0]}_{args[1]}_{args[2]}" + return f"{args[0]}_{args[2]}" + return f"{args[2]}" + return f"{args[1]}" + + +def get_ha_unit(sl_unit) -> str: + """Return equivalent Home Assistant unit of measurement if exists.""" + if (ha_unit := SL_UNIT_TO_HA_UNIT.get(sl_unit)) is not None: + return ha_unit + return sl_unit def cleanup_excluded_entity( coordinator: ScreenlogicDataUpdateCoordinator, platform_domain: str, - entity_key: str, + data_path: ScreenLogicDataPath, ) -> None: """Remove excluded entity if it exists.""" assert coordinator.config_entry entity_registry = er.async_get(coordinator.hass) - unique_id = f"{coordinator.config_entry.unique_id}_{entity_key}" + unique_id = f"{coordinator.config_entry.unique_id}_{generate_unique_id(*data_path)}" if entity_id := entity_registry.async_get_entity_id( platform_domain, SL_DOMAIN, unique_id ): diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py index 859841d3bea603..9ba7a19a9bed69 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -28,7 +28,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEBAND = (80, 40) -IGNORED_TIMEBAND = (3600, 1800) class SIAHub: @@ -100,7 +99,7 @@ def update_accounts(self): SIAAccount( account_id=a[CONF_ACCOUNT], key=a.get(CONF_ENCRYPTION_KEY), - allowed_timeband=IGNORED_TIMEBAND + allowed_timeband=None if a[CONF_IGNORE_TIMESTAMPS] else DEFAULT_TIMEBAND, ) diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 84ad68fabc3628..a35a92bf257d10 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -73,9 +73,9 @@ def _state_update(self): self._attr_native_value = self.meter.reading self.async_write_ha_state() - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self): """Subscribe to updates.""" + await super().async_added_to_hass() self.async_on_remove(self.coordinator.async_add_listener(self._state_update)) # If the background update finished before diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index 5128dc48b350a5..bace71aca55b36 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -11,6 +11,3 @@ PHOENIX_TIME_ZONE = "America/Phoenix" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) - -SENSOR_NAME = "Energy Usage" -SENSOR_TYPE = "usage" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index f6bd470df8a785..37aacf4ff25a4e 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -9,11 +9,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SRPEnergyDataUpdateCoordinator -from .const import DEFAULT_NAME, DOMAIN, SENSOR_NAME +from .const import DOMAIN async def async_setup_entry( @@ -29,10 +30,11 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) """Implementation of a Srp Energy Usage sensor.""" _attr_attribution = "Powered by SRP Energy" - _attr_icon = "mdi:flash" _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR _attr_device_class = SensorDeviceClass.ENERGY _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_has_entity_name = True + _attr_translation_key = "energy_usage" def __init__( self, coordinator: SRPEnergyDataUpdateCoordinator, config_entry: ConfigEntry @@ -40,7 +42,11 @@ def __init__( """Initialize the SrpEntity class.""" super().__init__(coordinator) self._attr_unique_id = f"{config_entry.entry_id}_total_usage" - self._attr_name = f"{DEFAULT_NAME} {SENSOR_NAME}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + name="SRP Energy", + entry_type=DeviceEntryType.SERVICE, + ) @property def native_value(self) -> float: diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index 3dddd96119461a..fd963411198edb 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -19,5 +19,12 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "sensor": { + "energy_usage": { + "name": "Energy usage" + } + } } } diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index c9cf452bac2b71..21f0036aabdd2e 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.35.1"] + "requirements": ["async-upnp-client==0.36.1"] } diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 626a03b785f4be..837d11eca7c8ae 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -29,8 +29,7 @@ import voluptuous as vol from yarl import URL -from homeassistant.components.logger import EVENT_LOGGING_CHANGED -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 6b8e6c44a1c087..bf66ef729bd3b6 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -4,6 +4,7 @@ import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable +from dataclasses import dataclass import datetime from enum import IntEnum import logging @@ -70,14 +71,14 @@ class StreamSettings: ) -@attr.s(slots=True) +@dataclass(slots=True) class Part: """Represent a segment part.""" - duration: float = attr.ib() - has_keyframe: bool = attr.ib() + duration: float + has_keyframe: bool # video data (moof+mdat) - data: bytes = attr.ib() + data: bytes @attr.s(slots=True) diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 45e9a96d7590ef..37158aa5fe309d 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -2,7 +2,7 @@ "domain": "stream", "name": "Stream", "codeowners": ["@hunterjm", "@uvjustin", "@allenporter"], - "dependencies": ["http", "logger"], + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/stream", "integration_type": "system", "iot_class": "local_push", diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 49a6af2b179a21..e685d1de806615 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.39.1"] + "requirements": ["PySwitchbot==0.40.0"] } diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 54c82d88c74bce..3329f185f08478 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -9,6 +9,7 @@ from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config import async_log_exception, config_without_domain from homeassistant.const import CONF_BINARY_SENSORS, CONF_SENSORS, CONF_UNIQUE_ID from homeassistant.helpers import config_validation as cv @@ -21,6 +22,7 @@ number as number_platform, select as select_platform, sensor as sensor_platform, + weather as weather_platform, ) from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN @@ -55,6 +57,9 @@ vol.Optional(IMAGE_DOMAIN): vol.All( cv.ensure_list, [image_platform.IMAGE_SCHEMA] ), + vol.Optional(WEATHER_DOMAIN): vol.All( + cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + ), } ) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index d815655d77524e..4e9149ebd07ef6 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -1,8 +1,9 @@ """Template platform that aggregates meteorological data.""" from __future__ import annotations +from dataclasses import asdict, dataclass from functools import partial -from typing import Any, Literal +from typing import Any, Literal, Self import voluptuous as vol @@ -22,18 +23,27 @@ ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, + DOMAIN as WEATHER_DOMAIN, ENTITY_ID_FORMAT, Forecast, WeatherEntity, WeatherEntityFeature, ) -from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID +from homeassistant.const import ( + CONF_NAME, + CONF_TEMPERATURE_UNIT, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_conversion import ( DistanceConverter, @@ -42,7 +52,9 @@ TemperatureConverter, ) +from .coordinator import TriggerUpdateCoordinator from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( set().union(Forecast.__annotations__.keys()) @@ -92,40 +104,38 @@ CONF_DEW_POINT_TEMPLATE = "dew_point_template" CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" +WEATHER_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.template, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + } +) + PLATFORM_SCHEMA = vol.All( cv.deprecated(CONF_FORECAST_TEMPLATE), - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_CONDITION_TEMPLATE): cv.template, - vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, - vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, - vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, - vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, - vol.Optional(CONF_OZONE_TEMPLATE): cv.template, - vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TEMPERATURE_UNIT): vol.In( - TemperatureConverter.VALID_UNITS - ), - vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), - vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), - vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), - vol.Optional(CONF_PRECIPITATION_UNIT): vol.In( - DistanceConverter.VALID_UNITS - ), - vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, - vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, - vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, - } - ), + PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema), ) @@ -136,6 +146,12 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" + if discovery_info and "coordinator" in discovery_info: + async_add_entities( + TriggerWeatherEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return config = rewrite_common_legacy_to_modern_conf(config) unique_id = config.get(CONF_UNIQUE_ID) @@ -452,3 +468,248 @@ def _validate_forecast( ) continue return result + + +@dataclass(kw_only=True) +class WeatherExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + last_apparent_temperature: float | None + last_cloud_coverage: int | None + last_dew_point: float | None + last_humidity: float | None + last_ozone: float | None + last_pressure: float | None + last_temperature: float | None + last_visibility: float | None + last_wind_bearing: float | str | None + last_wind_gust_speed: float | None + last_wind_speed: float | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the event data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored event state from a dict.""" + try: + return cls( + last_apparent_temperature=restored["last_apparent_temperature"], + last_cloud_coverage=restored["last_cloud_coverage"], + last_dew_point=restored["last_dew_point"], + last_humidity=restored["last_humidity"], + last_ozone=restored["last_ozone"], + last_pressure=restored["last_pressure"], + last_temperature=restored["last_temperature"], + last_visibility=restored["last_visibility"], + last_wind_bearing=restored["last_wind_bearing"], + last_wind_gust_speed=restored["last_wind_gust_speed"], + last_wind_speed=restored["last_wind_speed"], + ) + except KeyError: + return None + + +class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): + """Sensor entity based on trigger data.""" + + domain = WEATHER_DOMAIN + extra_template_keys = ( + CONF_CONDITION_TEMPLATE, + CONF_TEMPERATURE_TEMPLATE, + CONF_HUMIDITY_TEMPLATE, + ) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize.""" + super().__init__(hass, coordinator, config) + self._attr_native_precipitation_unit = config.get(CONF_PRECIPITATION_UNIT) + self._attr_native_pressure_unit = config.get(CONF_PRESSURE_UNIT) + self._attr_native_temperature_unit = config.get(CONF_TEMPERATURE_UNIT) + self._attr_native_visibility_unit = config.get(CONF_VISIBILITY_UNIT) + self._attr_native_wind_speed_unit = config.get(CONF_WIND_SPEED_UNIT) + + self._attr_supported_features = 0 + if config.get(CONF_FORECAST_DAILY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY + if config.get(CONF_FORECAST_HOURLY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY + if config.get(CONF_FORECAST_TWICE_DAILY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY + + for key in ( + CONF_APPARENT_TEMPERATURE_TEMPLATE, + CONF_CLOUD_COVERAGE_TEMPLATE, + CONF_DEW_POINT_TEMPLATE, + CONF_FORECAST_DAILY_TEMPLATE, + CONF_FORECAST_HOURLY_TEMPLATE, + CONF_FORECAST_TWICE_DAILY_TEMPLATE, + CONF_OZONE_TEMPLATE, + CONF_PRESSURE_TEMPLATE, + CONF_VISIBILITY_TEMPLATE, + CONF_WIND_BEARING_TEMPLATE, + CONF_WIND_GUST_SPEED_TEMPLATE, + CONF_WIND_SPEED_TEMPLATE, + ): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + (state := await self.async_get_last_state()) + and state.state is not None + and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and (weather_data := await self.async_get_last_weather_data()) + ): + self._rendered[ + CONF_APPARENT_TEMPERATURE_TEMPLATE + ] = weather_data.last_apparent_temperature + self._rendered[ + CONF_CLOUD_COVERAGE_TEMPLATE + ] = weather_data.last_cloud_coverage + self._rendered[CONF_CONDITION_TEMPLATE] = state.state + self._rendered[CONF_DEW_POINT_TEMPLATE] = weather_data.last_dew_point + self._rendered[CONF_HUMIDITY_TEMPLATE] = weather_data.last_humidity + self._rendered[CONF_OZONE_TEMPLATE] = weather_data.last_ozone + self._rendered[CONF_PRESSURE_TEMPLATE] = weather_data.last_pressure + self._rendered[CONF_TEMPERATURE_TEMPLATE] = weather_data.last_temperature + self._rendered[CONF_VISIBILITY_TEMPLATE] = weather_data.last_visibility + self._rendered[CONF_WIND_BEARING_TEMPLATE] = weather_data.last_wind_bearing + self._rendered[ + CONF_WIND_GUST_SPEED_TEMPLATE + ] = weather_data.last_wind_gust_speed + self._rendered[CONF_WIND_SPEED_TEMPLATE] = weather_data.last_wind_speed + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self._rendered.get(CONF_CONDITION_TEMPLATE) + + @property + def native_temperature(self) -> float | None: + """Return the temperature.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_TEMPERATURE_TEMPLATE) + ) + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_HUMIDITY_TEMPLATE) + ) + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_WIND_SPEED_TEMPLATE) + ) + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return vol.Any(vol.Coerce(float), vol.Coerce(str), None)( + self._rendered.get(CONF_WIND_BEARING_TEMPLATE) + ) + + @property + def ozone(self) -> float | None: + """Return the ozone level.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_OZONE_TEMPLATE), + ) + + @property + def native_visibility(self) -> float | None: + """Return the visibility.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_VISIBILITY_TEMPLATE) + ) + + @property + def native_pressure(self) -> float | None: + """Return the air pressure.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_PRESSURE_TEMPLATE) + ) + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE) + ) + + @property + def cloud_coverage(self) -> float | None: + """Return the cloud coverage.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_CLOUD_COVERAGE_TEMPLATE) + ) + + @property + def native_dew_point(self) -> float | None: + """Return the dew point.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_DEW_POINT_TEMPLATE) + ) + + @property + def native_apparent_temperature(self) -> float | None: + """Return the apparent temperature.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_APPARENT_TEMPERATURE_TEMPLATE) + ) + + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_DAILY_TEMPLATE) + ) + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_HOURLY_TEMPLATE) + ) + + async def async_forecast_twice_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_TWICE_DAILY_TEMPLATE) + ) + + @property + def extra_restore_state_data(self) -> WeatherExtraStoredData: + """Return weather specific state data to be restored.""" + return WeatherExtraStoredData( + last_apparent_temperature=self._rendered.get( + CONF_APPARENT_TEMPERATURE_TEMPLATE + ), + last_cloud_coverage=self._rendered.get(CONF_CLOUD_COVERAGE_TEMPLATE), + last_dew_point=self._rendered.get(CONF_DEW_POINT_TEMPLATE), + last_humidity=self._rendered.get(CONF_HUMIDITY_TEMPLATE), + last_ozone=self._rendered.get(CONF_OZONE_TEMPLATE), + last_pressure=self._rendered.get(CONF_PRESSURE_TEMPLATE), + last_temperature=self._rendered.get(CONF_TEMPERATURE_TEMPLATE), + last_visibility=self._rendered.get(CONF_VISIBILITY_TEMPLATE), + last_wind_bearing=self._rendered.get(CONF_WIND_BEARING_TEMPLATE), + last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE), + last_wind_speed=self._rendered.get(CONF_WIND_SPEED_TEMPLATE), + ) + + async def async_get_last_weather_data(self) -> WeatherExtraStoredData | None: + """Restore weather specific state data.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + return WeatherExtraStoredData.from_dict(restored_last_extra_data.as_dict()) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 228e2071b4a194..17712b6aef1bd9 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -205,7 +205,8 @@ def __init__(self, config: ConfigType) -> None: """Initialize a timer.""" self._config: dict = config self._state: str = STATUS_IDLE - self._duration = cv.time_period_str(config[CONF_DURATION]) + self._configured_duration = cv.time_period_str(config[CONF_DURATION]) + self._running_duration: timedelta = self._configured_duration self._remaining: timedelta | None = None self._end: datetime | None = None self._listener: Callable[[], None] | None = None @@ -248,7 +249,7 @@ def state(self): def extra_state_attributes(self): """Return the state attributes.""" attrs = { - ATTR_DURATION: _format_timedelta(self._duration), + ATTR_DURATION: _format_timedelta(self._running_duration), ATTR_EDITABLE: self.editable, } if self._end is not None: @@ -275,12 +276,12 @@ async def async_added_to_hass(self): # Begin restoring state self._state = state.state - self._duration = cv.time_period(state.attributes[ATTR_DURATION]) # Nothing more to do if the timer is idle if self._state == STATUS_IDLE: return + self._running_duration = cv.time_period(state.attributes[ATTR_DURATION]) # If the timer was paused, we restore the remaining time if self._state == STATUS_PAUSED: self._remaining = cv.time_period(state.attributes[ATTR_REMAINING]) @@ -314,11 +315,11 @@ def async_start(self, duration: timedelta | None = None): self._state = STATUS_ACTIVE start = dt_util.utcnow().replace(microsecond=0) - # Set remaining to new value if needed + # Set remaining and running duration unless resuming or restarting if duration: - self._remaining = self._duration = duration + self._remaining = self._running_duration = duration elif not self._remaining: - self._remaining = self._duration + self._remaining = self._running_duration self._end = start + self._remaining @@ -336,9 +337,9 @@ def async_change(self, duration: timedelta) -> None: raise HomeAssistantError( f"Timer {self.entity_id} is not running, only active timers can be changed" ) - if self._remaining and (self._remaining + duration) > self._duration: + if self._remaining and (self._remaining + duration) > self._running_duration: raise HomeAssistantError( - f"Not possible to change timer {self.entity_id} beyond configured duration" + f"Not possible to change timer {self.entity_id} beyond duration" ) if self._remaining and (self._remaining + duration) < timedelta(): raise HomeAssistantError( @@ -377,6 +378,7 @@ def async_cancel(self): self._state = STATUS_IDLE self._end = None self._remaining = None + self._running_duration = self._configured_duration self.hass.bus.async_fire( EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id} ) @@ -395,6 +397,7 @@ def async_finish(self): self._state = STATUS_IDLE self._end = None self._remaining = None + self._running_duration = self._configured_duration self.hass.bus.async_fire( EVENT_TIMER_FINISHED, {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, @@ -412,6 +415,7 @@ def _async_finished(self, time): end = self._end self._end = None self._remaining = None + self._running_duration = self._configured_duration self.hass.bus.async_fire( EVENT_TIMER_FINISHED, {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, @@ -421,6 +425,8 @@ def _async_finished(self, time): async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" self._config = config - self._duration = cv.time_period_str(config[CONF_DURATION]) + self._configured_duration = cv.time_period_str(config[CONF_DURATION]) + if self._state == STATUS_IDLE: + self._running_duration = self._configured_duration self._restore = config.get(CONF_RESTORE, DEFAULT_RESTORE) self.async_write_ha_state() diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index cd48af8536a2ec..4aa2748ad30c26 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -115,6 +115,7 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa name="Feels Like", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="dew_point", @@ -123,6 +124,7 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa icon="mdi:thermometer-water", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as hPa TomorrowioSensorEntityDescription( @@ -131,6 +133,7 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa name="Pressure (Surface Level)", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as W/m^2, convert to BTUs/(hr * ft^2) for imperial # https://www.theunitconverter.com/watt-square-meter-to-btu-hour-square-foot-conversion/ @@ -142,6 +145,7 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa unit_metric=UnitOfIrradiance.WATTS_PER_SQUARE_METER, imperial_conversion=(1 / 3.15459), device_class=SensorDeviceClass.IRRADIANCE, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( @@ -151,6 +155,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa icon="mdi:cloud-arrow-down", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, imperial_conversion=lambda val: DistanceConverter.convert( val, UnitOfLength.KILOMETERS, @@ -165,6 +171,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa icon="mdi:cloud-arrow-up", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, imperial_conversion=lambda val: DistanceConverter.convert( val, UnitOfLength.KILOMETERS, @@ -186,6 +194,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa icon="mdi:weather-windy", unit_imperial=UnitOfSpeed.MILES_PER_HOUR, unit_metric=UnitOfSpeed.METERS_PER_SECOND, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, imperial_conversion=lambda val: SpeedConverter.convert( val, UnitOfSpeed.METERS_PER_SECOND, UnitOfSpeed.MILES_PER_HOUR ), @@ -207,6 +217,7 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(48), device_class=SensorDeviceClass.OZONE, + state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="particulate_matter_2_5_mm", @@ -214,6 +225,7 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa name="Particulate Matter < 2.5 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="particulate_matter_10_mm", @@ -221,6 +233,7 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa name="Particulate Matter < 10 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Nitrogen Dioxide is 46.01 @@ -231,6 +244,7 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(46.01), device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as ppb, convert to ppm TomorrowioSensorEntityDescription( @@ -240,6 +254,7 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, multiplication_factor=1 / 1000, device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Sulphur Dioxide is 64.07 @@ -250,12 +265,14 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(64.07), device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="us_epa_air_quality_index", attribute=TMRW_ATTR_EPA_AQI, name="US EPA Air Quality Index", device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="us_epa_primary_pollutant", diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 089e82b0f0759c..2d00f35202c871 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -25,6 +25,7 @@ CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_SENSORS, + STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -37,6 +38,7 @@ async_track_state_change_event, ) from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from homeassistant.util.dt import utcnow @@ -116,7 +118,7 @@ async def async_setup_platform( async_add_entities(sensors) -class SensorTrend(BinarySensorEntity): +class SensorTrend(BinarySensorEntity, RestoreEntity): """Representation of a trend Sensor.""" _attr_should_poll = False @@ -194,6 +196,12 @@ def trend_sensor_state_listener( ) ) + if not (state := await self.async_get_last_state()): + return + if state.state == STATE_UNKNOWN: + return + self._state = state.state == STATE_ON + async def async_update(self) -> None: """Get the latest data and update the states.""" # Remove outdated samples diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index 3b0228e64b042b..897bfaf4e20e71 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -6,12 +6,12 @@ from ttls.client import Twinkly from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_SW_VERSION, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_HOST, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN +from .const import ATTR_VERSION, CONF_HOST, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN PLATFORMS = [Platform.LIGHT] @@ -30,12 +30,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: device_info = await client.get_details() + software_version = await client.get_firmware_version() except (asyncio.TimeoutError, ClientError) as exception: raise ConfigEntryNotReady from exception - hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] = client - hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_INFO] = device_info - + hass.data[DOMAIN][entry.entry_id] = { + DATA_CLIENT: client, + DATA_DEVICE_INFO: device_info, + ATTR_SW_VERSION: software_version.get(ATTR_VERSION), + } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/twinkly/diagnostics.py b/homeassistant/components/twinkly/diagnostics.py index 06afba5782bd2d..598eab0fca5ecd 100644 --- a/homeassistant/components/twinkly/diagnostics.py +++ b/homeassistant/components/twinkly/diagnostics.py @@ -6,7 +6,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_MAC +from homeassistant.const import ATTR_SW_VERSION, CONF_HOST, CONF_IP_ADDRESS, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -34,6 +34,7 @@ async def async_get_config_entry_diagnostics( { "entry": entry.as_dict(), "device_info": hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_INFO], + ATTR_SW_VERSION: hass.data[DOMAIN][entry.entry_id][ATTR_SW_VERSION], "attributes": attributes, }, TO_REDACT, diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 66f764f17f6b4c..6d0b31b06ed388 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -19,13 +19,13 @@ LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL +from homeassistant.const import ATTR_SW_VERSION, CONF_MODEL from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - ATTR_VERSION, CONF_HOST, CONF_ID, CONF_NAME, @@ -52,8 +52,9 @@ async def async_setup_entry( client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] device_info = hass.data[DOMAIN][config_entry.entry_id][DATA_DEVICE_INFO] + software_version = hass.data[DOMAIN][config_entry.entry_id][ATTR_SW_VERSION] - entity = TwinklyLight(config_entry, client, device_info) + entity = TwinklyLight(config_entry, client, device_info, software_version) async_add_entities([entity], update_before_add=True) @@ -68,6 +69,7 @@ def __init__( conf: ConfigEntry, client: Twinkly, device_info, + software_version: str | None = None, ) -> None: """Initialize a TwinklyLight entity.""" self._attr_unique_id: str = conf.data[CONF_ID] @@ -98,7 +100,7 @@ def __init__( self._attr_available = False self._current_movie: dict[Any, Any] = {} self._movies: list[Any] = [] - self._software_version = "" + self._software_version = software_version # We guess that most devices are "new" and support effects self._attr_supported_features = LightEntityFeature.EFFECT @@ -135,16 +137,21 @@ def effect_list(self) -> list[str]: async def async_added_to_hass(self) -> None: """Device is added to hass.""" - software_version = await self._client.get_firmware_version() - if ATTR_VERSION in software_version: - self._software_version = software_version[ATTR_VERSION] - + if self._software_version: if AwesomeVersion(self._software_version) < AwesomeVersion( MIN_EFFECT_VERSION ): self._attr_supported_features = ( self.supported_features & ~LightEntityFeature.EFFECT ) + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + {(DOMAIN, self._attr_unique_id)}, set() + ) + if device_entry: + device_registry.async_update_device( + device_entry.id, sw_version=self._software_version + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn device on.""" diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py index 64feb17d6b5c97..76b6ec709ff179 100644 --- a/homeassistant/components/twitch/__init__.py +++ b/homeassistant/components/twitch/__init__.py @@ -1 +1,53 @@ """The Twitch component.""" +from __future__ import annotations + +from aiohttp.client_exceptions import ClientError, ClientResponseError +from twitchAPI.twitch import Twitch + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .const import DOMAIN, OAUTH_SCOPES, PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Twitch from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err + + app_id = implementation.__dict__[CONF_CLIENT_ID] + access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + client = await Twitch( + app_id=app_id, + authenticate_app=False, + ) + client.auto_refresh_auth = False + await client.set_user_authentication(access_token, scope=OAUTH_SCOPES) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Twitch config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/twitch/application_credentials.py b/homeassistant/components/twitch/application_credentials.py new file mode 100644 index 00000000000000..fd8b03db2ca3fa --- /dev/null +++ b/homeassistant/components/twitch/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the Twitch integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(_: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/twitch/config_flow.py b/homeassistant/components/twitch/config_flow.py new file mode 100644 index 00000000000000..9e586b19a5a34b --- /dev/null +++ b/homeassistant/components/twitch/config_flow.py @@ -0,0 +1,189 @@ +"""Config flow for Twitch.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from twitchAPI.helper import first +from twitchAPI.twitch import Twitch +from twitchAPI.type import AuthScope, InvalidTokenException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_TOKEN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import CONF_CHANNELS, CONF_REFRESH_TOKEN, DOMAIN, LOGGER, OAUTH_SCOPES + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Twitch OAuth2 authentication.""" + + DOMAIN = DOMAIN + reauth_entry: ConfigEntry | None = None + + def __init__(self) -> None: + """Initialize flow.""" + super().__init__() + self.data: dict[str, Any] = {} + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return LOGGER + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join([scope.value for scope in OAUTH_SCOPES])} + + async def async_oauth_create_entry( + self, + data: dict[str, Any], + ) -> FlowResult: + """Handle the initial step.""" + + client = await Twitch( + app_id=self.flow_impl.__dict__[CONF_CLIENT_ID], + authenticate_app=False, + ) + client.auto_refresh_auth = False + await client.set_user_authentication( + data[CONF_TOKEN][CONF_ACCESS_TOKEN], scope=OAUTH_SCOPES + ) + user = await first(client.get_users()) + assert user + + user_id = user.id + + if not self.reauth_entry: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + + channels = [ + channel.broadcaster_login + async for channel in await client.get_followed_channels(user_id) + ] + + return self.async_create_entry( + title=user.display_name, data=data, options={CONF_CHANNELS: channels} + ) + + if self.reauth_entry.unique_id == user_id: + new_channels = self.reauth_entry.options[CONF_CHANNELS] + # Since we could not get all channels at import, we do it at the reauth + # immediately after. + if "imported" in self.reauth_entry.data: + channels = [ + channel.broadcaster_login + async for channel in await client.get_followed_channels(user_id) + ] + options = list(set(channels) - set(new_channels)) + new_channels = [*new_channels, *options] + + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data=data, + options={CONF_CHANNELS: new_channels}, + ) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_abort( + reason="wrong_account", + description_placeholders={"title": self.reauth_entry.title}, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import from yaml.""" + client = await Twitch( + app_id=config[CONF_CLIENT_ID], + authenticate_app=False, + ) + client.auto_refresh_auth = False + token = config[CONF_TOKEN] + try: + await client.set_user_authentication( + token, validate=True, scope=[AuthScope.USER_READ_SUBSCRIPTIONS] + ) + except InvalidTokenException: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_invalid_token", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_invalid_token", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, + ) + return self.async_abort(reason="invalid_token") + user = await first(client.get_users()) + assert user + await self.async_set_unique_id(user.id) + try: + self._abort_if_unique_id_configured() + except AbortFlow as err: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_already_imported", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_already_imported", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, + ) + raise err + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, + ) + return self.async_create_entry( + title=user.display_name, + data={ + "auth_implementation": DOMAIN, + CONF_TOKEN: { + CONF_ACCESS_TOKEN: token, + CONF_REFRESH_TOKEN: "", + "expires_at": 0, + }, + "imported": True, + }, + options={CONF_CHANNELS: config[CONF_CHANNELS]}, + ) diff --git a/homeassistant/components/twitch/const.py b/homeassistant/components/twitch/const.py index 6626889a809754..22286437eab0b9 100644 --- a/homeassistant/components/twitch/const.py +++ b/homeassistant/components/twitch/const.py @@ -3,8 +3,18 @@ from twitchAPI.twitch import AuthScope +from homeassistant.const import Platform + LOGGER = logging.getLogger(__package__) +PLATFORMS = [Platform.SENSOR] + +OAUTH2_AUTHORIZE = "https://id.twitch.tv/oauth2/authorize" +OAUTH2_TOKEN = "https://id.twitch.tv/oauth2/token" + +CONF_REFRESH_TOKEN = "refresh_token" + +DOMAIN = "twitch" CONF_CHANNELS = "channels" -OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS] +OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS, AuthScope.USER_READ_FOLLOWS] diff --git a/homeassistant/components/twitch/manifest.json b/homeassistant/components/twitch/manifest.json index 5613360c5942c0..810982d0cb4328 100644 --- a/homeassistant/components/twitch/manifest.json +++ b/homeassistant/components/twitch/manifest.json @@ -2,8 +2,10 @@ "domain": "twitch", "name": "Twitch", "codeowners": ["@joostlek"], + "config_flow": true, + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/twitch", "iot_class": "cloud_polling", "loggers": ["twitch"], - "requirements": ["twitchAPI==3.10.0"] + "requirements": ["twitchAPI==4.0.0"] } diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 3211ca1952bcbe..11d6611ef9908d 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -4,24 +4,27 @@ from twitchAPI.helper import first from twitchAPI.twitch import ( AuthType, - InvalidTokenException, - MissingScopeException, Twitch, TwitchAPIException, - TwitchAuthorizationException, TwitchResourceNotFound, TwitchUser, ) import voluptuous as vol +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_CHANNELS, LOGGER, OAUTH_SCOPES +from .const import CONF_CHANNELS, DOMAIN, LOGGER, OAUTH_SCOPES PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -56,40 +59,46 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Twitch platform.""" - channels = config[CONF_CHANNELS] - client_id = config[CONF_CLIENT_ID] - client_secret = config[CONF_CLIENT_SECRET] - oauth_token = config.get(CONF_TOKEN) - - try: - client = await Twitch( - app_id=client_id, - app_secret=client_secret, - target_app_auth_scope=OAUTH_SCOPES, + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET]), + ) + if CONF_TOKEN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_credentials_imported", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_credentials_imported", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, ) - client.auto_refresh_auth = False - except TwitchAuthorizationException: - LOGGER.error("Invalid client ID or client secret") - return - if oauth_token: - try: - await client.set_user_authentication( - token=oauth_token, scope=OAUTH_SCOPES, validate=True - ) - except MissingScopeException: - LOGGER.error("OAuth token is missing required scope") - return - except InvalidTokenException: - LOGGER.error("OAuth token is invalid") - return - twitch_users: list[TwitchUser] = [] - async for channel in client.get_users(logins=channels): - twitch_users.append(channel) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize entries.""" + client = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [TwitchSensor(channel, client) for channel in twitch_users], + [ + TwitchSensor(channel, client) + async for channel in client.get_users(logins=entry.options[CONF_CHANNELS]) + ], True, ) @@ -109,7 +118,7 @@ def __init__(self, channel: TwitchUser, client: Twitch) -> None: async def async_update(self) -> None: """Update device state.""" - followers = (await self._client.get_users_follows(to_id=self._channel.id)).total + followers = (await self._client.get_channel_followers(self._channel.id)).total self._attr_extra_state_attributes = { ATTR_FOLLOWING: followers, ATTR_VIEWS: self._channel.view_count, @@ -149,13 +158,11 @@ async def _async_add_user_attributes(self) -> None: except TwitchAPIException as exc: LOGGER.error("Error response on check_user_subscription: %s", exc) - follows = ( - await self._client.get_users_follows( - from_id=user.id, to_id=self._channel.id - ) - ).data - self._attr_extra_state_attributes[ATTR_FOLLOW] = len(follows) > 0 - if len(follows): - self._attr_extra_state_attributes[ATTR_FOLLOW_SINCE] = follows[ + follows = await self._client.get_followed_channels( + user.id, broadcaster_id=self._channel.id + ) + self._attr_extra_state_attributes[ATTR_FOLLOW] = follows.total > 0 + if follows.total: + self._attr_extra_state_attributes[ATTR_FOLLOW_SINCE] = follows.data[ 0 ].followed_at diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json new file mode 100644 index 00000000000000..45f8874712875e --- /dev/null +++ b/homeassistant/components/twitch/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Twitch integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with {username}." + } + }, + "issues": { + "deprecated_yaml_invalid_token": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour configuration couldn't be imported because the token in the configuration.yaml was invalid.\n\nPlease add Twitch again via the UI.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "deprecated_yaml_credentials_imported": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour application credentials are imported, but a config entry could not be created because there was no access token.\n\nPlease add Twitch again via the UI.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "deprecated_yaml_already_imported": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour application credentials are already imported.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 0bde41ac61132d..4337899a50fd75 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if len(hass.data[UNIFI_DOMAIN]) == 1: async_setup_services(hass) - api.start_websocket() + controller.start_websocket() config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, controller.shutdown) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 9f965b424ffd39..620b928176ed7f 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -12,7 +12,6 @@ from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.models.configuration import Configuration from aiounifi.models.device import DeviceSetPoePortModeRequest -from aiounifi.websocket import WebsocketState from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -81,7 +80,7 @@ def __init__( self.config_entry = config_entry self.api = api - api.ws_state_callback = self.async_unifi_ws_state_callback + self.ws_task: asyncio.Task | None = None self.available = True self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] @@ -223,23 +222,6 @@ def async_options_updated() -> None: for description in descriptions: async_load_entities(description) - @callback - def async_unifi_ws_state_callback(self, state: WebsocketState) -> None: - """Handle messages back from UniFi library.""" - if state == WebsocketState.DISCONNECTED and self.available: - LOGGER.warning("Lost connection to UniFi Network") - - if (state == WebsocketState.RUNNING and not self.available) or ( - state == WebsocketState.DISCONNECTED and self.available - ): - self.available = state == WebsocketState.RUNNING - async_dispatcher_send(self.hass, self.signal_reachable) - - if not self.available: - self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) - else: - LOGGER.info("Connected to UniFi Network") - @property def signal_reachable(self) -> str: """Integration specific event to signal a change in connection status.""" @@ -367,6 +349,19 @@ async def async_config_entry_updated( controller.load_config_entry_options() async_dispatcher_send(hass, controller.signal_options_update) + @callback + def start_websocket(self) -> None: + """Start up connection to websocket.""" + + async def _websocket_runner() -> None: + """Start websocket.""" + await self.api.start_websocket() + self.available = False + async_dispatcher_send(self.hass, self.signal_reachable) + self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) + + self.ws_task = self.hass.loop.create_task(_websocket_runner()) + @callback def reconnect(self, log: bool = False) -> None: """Prepare to reconnect UniFi session.""" @@ -379,7 +374,11 @@ async def async_reconnect(self) -> None: try: async with asyncio.timeout(5): await self.api.login() - self.api.start_websocket() + self.start_websocket() + + if not self.available: + self.available = True + async_dispatcher_send(self.hass, self.signal_reachable) except ( asyncio.TimeoutError, @@ -395,7 +394,8 @@ def shutdown(self, event: Event) -> None: Used as an argument to EventBus.async_listen_once. """ - self.api.stop_websocket() + if self.ws_task is not None: + self.ws_task.cancel() async def async_reset(self) -> bool: """Reset this controller to default state. @@ -403,7 +403,18 @@ async def async_reset(self) -> bool: Will cancel any scheduled setup retry and will unload the config entry. """ - self.api.stop_websocket() + if self.ws_task is not None: + self.ws_task.cancel() + + _, pending = await asyncio.wait([self.ws_task], timeout=10) + + if pending: + LOGGER.warning( + "Unloading %s (%s) config entry. Task %s did not complete in time", + self.config_entry.title, + self.config_entry.domain, + self.ws_task, + ) unload_ok = await self.hass.config_entries.async_unload_platforms( self.config_entry, PLATFORMS diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 8734fd7dce555d..7673402aaac43b 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==62"], + "requirements": ["aiounifi==63"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index e42235af7479dd..1651dea6612793 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.35.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.36.1", "getmac==0.8.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index dcf8e7d2860221..fb892acfd4fa49 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==2.1.1"] + "requirements": ["pyvesync==2.1.10"] } diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index cf2a22d2dbc874..816e9241739274 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -7,7 +7,7 @@ from .const import DOMAIN from .coordinator import VodafoneStationRouter -PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.BUTTON] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py new file mode 100644 index 00000000000000..7f93f8023efab0 --- /dev/null +++ b/homeassistant/components/vodafone_station/button.py @@ -0,0 +1,113 @@ +"""Vodafone Station buttons.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import _LOGGER, DOMAIN +from .coordinator import VodafoneStationRouter + + +@dataclass +class VodafoneStationBaseEntityDescriptionMixin: + """Mixin to describe a Button entity.""" + + press_action: Callable[[VodafoneStationRouter], Any] + is_suitable: Callable[[dict], bool] + + +@dataclass +class VodafoneStationEntityDescription( + ButtonEntityDescription, VodafoneStationBaseEntityDescriptionMixin +): + """Vodafone Station entity description.""" + + +BUTTON_TYPES: Final = ( + VodafoneStationEntityDescription( + key="reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=lambda coordinator: coordinator.api.restart_router(), + is_suitable=lambda _: True, + ), + VodafoneStationEntityDescription( + key="dsl_ready", + translation_key="dsl_reconnect", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda coordinator: coordinator.api.restart_connection("dsl"), + is_suitable=lambda info: info.get("dsl_ready") == "1", + ), + VodafoneStationEntityDescription( + key="fiber_ready", + translation_key="fiber_reconnect", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda coordinator: coordinator.api.restart_connection("fiber"), + is_suitable=lambda info: info.get("fiber_ready") == "1", + ), + VodafoneStationEntityDescription( + key="vf_internet_key_online_since", + translation_key="internet_key_reconnect", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda coordinator: coordinator.api.restart_connection( + "internet_key" + ), + is_suitable=lambda info: info.get("vf_internet_key_online_since") != "", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entry.""" + _LOGGER.debug("Setting up Vodafone Station buttons") + + coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + + sensors_data = coordinator.data.sensors + + async_add_entities( + VodafoneStationSensorEntity(coordinator, sensor_descr) + for sensor_descr in BUTTON_TYPES + if sensor_descr.is_suitable(sensors_data) + ) + + +class VodafoneStationSensorEntity( + CoordinatorEntity[VodafoneStationRouter], ButtonEntity +): + """Representation of a Vodafone Station button.""" + + _attr_has_entity_name = True + entity_description: VodafoneStationEntityDescription + + def __init__( + self, + coordinator: VodafoneStationRouter, + description: VodafoneStationEntityDescription, + ) -> None: + """Initialize a Vodafone Station sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + + async def async_press(self) -> None: + """Triggers the Shelly button press service.""" + await self.entity_description.press_action(self.coordinator) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index e4a087f6903ece..45bb263d371c79 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -68,10 +68,14 @@ async def async_step_user( try: info = await validate_input(self.hass, user_input) + except aiovodafone_exceptions.AlreadyLogged: + errors["base"] = "already_logged" except aiovodafone_exceptions.CannotConnect: errors["base"] = "cannot_connect" except aiovodafone_exceptions.CannotAuthenticate: errors["base"] = "invalid_auth" + except aiovodafone_exceptions.ModelNotSupported: + errors["base"] = "model_not_supported" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -99,6 +103,8 @@ async def async_step_reauth_confirm( if user_input is not None: try: await validate_input(self.hass, {**self.entry.data, **user_input}) + except aiovodafone_exceptions.AlreadyLogged: + errors["base"] = "already_logged" except aiovodafone_exceptions.CannotConnect: errors["base"] = "cannot_connect" except aiovodafone_exceptions.CannotAuthenticate: diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 58079180bf8cca..fe1ff1889d552b 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -8,6 +8,7 @@ from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -122,3 +123,22 @@ async def _async_update_data(self) -> UpdateCoordinatorDataType: def signal_device_new(self) -> str: """Event specific per Vodafone Station entry to signal new device.""" return f"{DOMAIN}-device-new-{self._id}" + + @property + def serial_number(self) -> str: + """Device serial number.""" + return self.data.sensors["sys_serial_number"] + + @property + def device_info(self) -> DeviceInfo: + """Set device info.""" + sensors_data = self.data.sensors + return DeviceInfo( + configuration_url=self.api.base_url, + identifiers={(DOMAIN, self.serial_number)}, + name=f"Vodafone Station ({self.serial_number})", + manufacturer="Vodafone", + model=sensors_data.get("sys_model_name"), + hw_version=sensors_data["sys_hardware_version"], + sw_version=sensors_data["sys_firmware_version"], + ) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 2d72e7d9482cef..d37fed9564f7e4 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.3.0"] + "requirements": ["aiovodafone==0.3.1"] } diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 0ca705ad56bdec..ce2d3154de3b9d 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -193,21 +192,9 @@ def __init__( ) -> None: """Initialize a Vodafone Station sensor.""" super().__init__(coordinator) - - sensors_data = coordinator.data.sensors - serial_num = sensors_data["sys_serial_number"] self.entity_description = description - - self._attr_device_info = DeviceInfo( - configuration_url=coordinator.api.base_url, - identifiers={(DOMAIN, serial_num)}, - name=f"Vodafone Station ({serial_num})", - manufacturer="Vodafone", - model=sensors_data.get("sys_model_name"), - hw_version=sensors_data["sys_hardware_version"], - sw_version=sensors_data["sys_firmware_version"], - ) - self._attr_unique_id = f"{serial_num}_{description.key}" + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 0c2a4a408dda8b..aaaa27a3614c57 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -12,25 +12,33 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]" + "password": "[%key:common::config_flow::data::password%]" } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "already_logged": "User already logged-in, please try again later.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "model_not_supported": "The device model is currently unsupported.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { + "already_logged": "User already logged-in, please try again later.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "model_not_supported": "The device model is currently unsupported.", "unknown": "[%key:common::config_flow::error::unknown%]" } }, "entity": { + "button": { + "dsl_reconnect": { "name": "DSL reconnect" }, + "fiber_reconnect": { "name": "Fiber reconnect" }, + "internet_key_reconnect": { "name": "Internet key reconnect" } + }, "sensor": { "external_ipv4": { "name": "WAN IPv4 address" }, "external_ipv6": { "name": "WAN IPv6 address" }, diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index efa62e0e8f4c5a..6ea972686845f6 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -29,8 +29,11 @@ select as pipeline_select, ) from homeassistant.components.assist_pipeline.vad import ( + AudioBuffer, VadSensitivity, + VoiceActivityDetector, VoiceCommandSegmenter, + WebRtcVad, ) from homeassistant.const import __version__ from homeassistant.core import Context, HomeAssistant @@ -225,11 +228,13 @@ async def _run_pipeline( try: # Wait for speech before starting pipeline segmenter = VoiceCommandSegmenter(silence_seconds=self.silence_seconds) + vad = WebRtcVad() chunk_buffer: deque[bytes] = deque( maxlen=self.buffered_chunks_before_speech, ) speech_detected = await self._wait_for_speech( segmenter, + vad, chunk_buffer, ) if not speech_detected: @@ -243,6 +248,7 @@ async def stt_stream(): try: async for chunk in self._segment_audio( segmenter, + vad, chunk_buffer, ): yield chunk @@ -306,6 +312,7 @@ async def stt_stream(): async def _wait_for_speech( self, segmenter: VoiceCommandSegmenter, + vad: VoiceActivityDetector, chunk_buffer: MutableSequence[bytes], ): """Buffer audio chunks until speech is detected. @@ -317,12 +324,18 @@ async def _wait_for_speech( async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() + assert vad.samples_per_chunk is not None + vad_buffer = AudioBuffer(vad.samples_per_chunk * WIDTH) + while chunk: chunk_buffer.append(chunk) - segmenter.process(chunk) + segmenter.process_with_vad(chunk, vad, vad_buffer) if segmenter.in_command: # Buffer until command starts + if len(vad_buffer) > 0: + chunk_buffer.append(vad_buffer.bytes()) + return True async with asyncio.timeout(self.audio_timeout): @@ -333,6 +346,7 @@ async def _wait_for_speech( async def _segment_audio( self, segmenter: VoiceCommandSegmenter, + vad: VoiceActivityDetector, chunk_buffer: Sequence[bytes], ) -> AsyncIterable[bytes]: """Yield audio chunks until voice command has finished.""" @@ -345,8 +359,11 @@ async def _segment_audio( async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() + assert vad.samples_per_chunk is not None + vad_buffer = AudioBuffer(vad.samples_per_chunk * WIDTH) + while chunk: - if not segmenter.process(chunk): + if not segmenter.process_with_vad(chunk, vad, vad_buffer): # Voice command is finished break diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index b308cf9891286f..6c55bd8e7e729e 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -6,6 +6,9 @@ import logging from typing import final +import voluptuous as vol + +from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant, callback @@ -19,7 +22,7 @@ from .models import DetectionResult, WakeWord __all__ = [ - "async_default_engine", + "async_default_entity", "async_get_wake_word_detection_entity", "DetectionResult", "DOMAIN", @@ -33,8 +36,8 @@ @callback -def async_default_engine(hass: HomeAssistant) -> str | None: - """Return the domain or entity id of the default engine.""" +def async_default_entity(hass: HomeAssistant) -> str | None: + """Return the entity id of the default engine.""" return next(iter(hass.states.async_entity_ids(DOMAIN)), None) @@ -49,7 +52,9 @@ def async_get_wake_word_detection_entity( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up STT.""" + """Set up wake word.""" + websocket_api.async_register_command(hass, websocket_entity_info) + component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) component.register_shutdown() @@ -88,7 +93,7 @@ def supported_wake_words(self) -> list[WakeWord]: @abstractmethod async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps. @@ -96,13 +101,13 @@ async def _async_process_audio_stream( """ async def async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps. Audio must be 16Khz sample rate with 16-bit mono PCM samples. """ - result = await self._async_process_audio_stream(stream) + result = await self._async_process_audio_stream(stream, wake_word_id) if result is not None: # Update last detected only when there is a detection self.__last_detected = dt_util.utcnow().isoformat() @@ -120,3 +125,29 @@ async def async_internal_added_to_hass(self) -> None: and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) ): self.__last_detected = state.state + + +@websocket_api.websocket_command( + { + "type": "wake_word/info", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + } +) +@callback +def websocket_entity_info( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Get info about wake word entity.""" + component: EntityComponent[WakeWordDetectionEntity] = hass.data[DOMAIN] + entity = component.get_entity(msg["entity_id"]) + + if entity is None: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Entity not found" + ) + return + + connection.send_result( + msg["id"], + {"wake_words": entity.supported_wake_words}, + ) diff --git a/homeassistant/components/wake_word/models.py b/homeassistant/components/wake_word/models.py index 1ea154f1393489..8e0699d97d02b4 100644 --- a/homeassistant/components/wake_word/models.py +++ b/homeassistant/components/wake_word/models.py @@ -6,7 +6,7 @@ class WakeWord: """Wake word model.""" - ww_id: str + id: str name: str @@ -14,7 +14,7 @@ class WakeWord: class DetectionResult: """Result of wake word detection.""" - ww_id: str + wake_word_id: str """Id of detected wake word""" timestamp: int | None diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 9b27b9c4bd129f..4db217d0a544f4 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -246,6 +246,8 @@ class InvalidAuth(HomeAssistantError): class WallboxEntity(CoordinatorEntity[WallboxCoordinator]): """Defines a base Wallbox entity.""" + _attr_has_entity_name = True + @property def device_info(self) -> DeviceInfo: """Return device information about this Wallbox device.""" @@ -256,7 +258,7 @@ def device_info(self) -> DeviceInfo: self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY], ) }, - name=f"Wallbox - {self.coordinator.data[CHARGER_NAME_KEY]}", + name=f"Wallbox {self.coordinator.data[CHARGER_NAME_KEY]}", manufacturer="Wallbox", model=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY], sw_version=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SOFTWARE_KEY][ diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 7b5dca58010716..04a587ae34d4bb 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -20,7 +20,7 @@ LOCK_TYPES: dict[str, LockEntityDescription] = { CHARGER_LOCKED_UNLOCKED_KEY: LockEntityDescription( key=CHARGER_LOCKED_UNLOCKED_KEY, - name="Locked/Unlocked", + translation_key="lock", ), } @@ -42,7 +42,7 @@ async def async_setup_entry( async_add_entities( [ - WallboxLock(coordinator, entry, description) + WallboxLock(coordinator, description) for ent in coordinator.data if (description := LOCK_TYPES.get(ent)) ] @@ -55,14 +55,12 @@ class WallboxLock(WallboxEntity, LockEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, description: LockEntityDescription, ) -> None: """Initialize a Wallbox lock.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" @property diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 58d4a5e6afb05d..b8ce331146d575 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -33,7 +33,7 @@ class WallboxNumberEntityDescription(NumberEntityDescription): NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_CHARGING_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_CHARGING_CURRENT_KEY, - name="Max. Charging Current", + translation_key="maximum_charging_current", ), } @@ -77,7 +77,6 @@ def __init__( super().__init__(coordinator) self.entity_description = description self._coordinator = coordinator - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" self._is_bidirectional = ( coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:3] diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index afd2b13f79096e..56d9e0be735fad 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -60,7 +60,7 @@ class WallboxSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { CHARGER_CHARGING_POWER_KEY: WallboxSensorEntityDescription( key=CHARGER_CHARGING_POWER_KEY, - name="Charging Power", + translation_key=CHARGER_CHARGING_POWER_KEY, precision=2, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, @@ -68,7 +68,7 @@ class WallboxSensorEntityDescription(SensorEntityDescription): ), CHARGER_MAX_AVAILABLE_POWER_KEY: WallboxSensorEntityDescription( key=CHARGER_MAX_AVAILABLE_POWER_KEY, - name="Max Available Power", + translation_key=CHARGER_MAX_AVAILABLE_POWER_KEY, precision=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -76,15 +76,15 @@ class WallboxSensorEntityDescription(SensorEntityDescription): ), CHARGER_CHARGING_SPEED_KEY: WallboxSensorEntityDescription( key=CHARGER_CHARGING_SPEED_KEY, + translation_key=CHARGER_CHARGING_SPEED_KEY, icon="mdi:speedometer", - name="Charging Speed", precision=0, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_ADDED_RANGE_KEY: WallboxSensorEntityDescription( key=CHARGER_ADDED_RANGE_KEY, + translation_key=CHARGER_ADDED_RANGE_KEY, icon="mdi:map-marker-distance", - name="Added Range", precision=0, native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, @@ -92,7 +92,7 @@ class WallboxSensorEntityDescription(SensorEntityDescription): ), CHARGER_ADDED_ENERGY_KEY: WallboxSensorEntityDescription( key=CHARGER_ADDED_ENERGY_KEY, - name="Added Energy", + translation_key=CHARGER_ADDED_ENERGY_KEY, precision=2, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -100,7 +100,7 @@ class WallboxSensorEntityDescription(SensorEntityDescription): ), CHARGER_ADDED_DISCHARGED_ENERGY_KEY: WallboxSensorEntityDescription( key=CHARGER_ADDED_DISCHARGED_ENERGY_KEY, - name="Discharged Energy", + translation_key=CHARGER_ADDED_DISCHARGED_ENERGY_KEY, precision=2, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -108,44 +108,44 @@ class WallboxSensorEntityDescription(SensorEntityDescription): ), CHARGER_COST_KEY: WallboxSensorEntityDescription( key=CHARGER_COST_KEY, + translation_key=CHARGER_COST_KEY, icon="mdi:ev-station", - name="Cost", state_class=SensorStateClass.TOTAL_INCREASING, ), CHARGER_STATE_OF_CHARGE_KEY: WallboxSensorEntityDescription( key=CHARGER_STATE_OF_CHARGE_KEY, - name="State of Charge", + translation_key=CHARGER_STATE_OF_CHARGE_KEY, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_CURRENT_MODE_KEY: WallboxSensorEntityDescription( key=CHARGER_CURRENT_MODE_KEY, + translation_key=CHARGER_CURRENT_MODE_KEY, icon="mdi:ev-station", - name="Current Mode", ), CHARGER_DEPOT_PRICE_KEY: WallboxSensorEntityDescription( key=CHARGER_DEPOT_PRICE_KEY, + translation_key=CHARGER_DEPOT_PRICE_KEY, icon="mdi:ev-station", - name="Depot Price", precision=2, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_ENERGY_PRICE_KEY: WallboxSensorEntityDescription( key=CHARGER_ENERGY_PRICE_KEY, + translation_key=CHARGER_ENERGY_PRICE_KEY, icon="mdi:ev-station", - name="Energy Price", precision=2, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_STATUS_DESCRIPTION_KEY: WallboxSensorEntityDescription( key=CHARGER_STATUS_DESCRIPTION_KEY, + translation_key=CHARGER_STATUS_DESCRIPTION_KEY, icon="mdi:ev-station", - name="Status Description", ), CHARGER_MAX_CHARGING_CURRENT_KEY: WallboxSensorEntityDescription( key=CHARGER_MAX_CHARGING_CURRENT_KEY, - name="Max. Charging Current", + translation_key=CHARGER_MAX_CHARGING_CURRENT_KEY, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -161,7 +161,7 @@ async def async_setup_entry( async_add_entities( [ - WallboxSensor(coordinator, entry, description) + WallboxSensor(coordinator, description) for ent in coordinator.data if (description := SENSOR_TYPES.get(ent)) ] @@ -176,13 +176,11 @@ class WallboxSensor(WallboxEntity, SensorEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, description: WallboxSensorEntityDescription, ) -> None: """Initialize a Wallbox sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" @property diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 4cde9c6d255be3..69db4bb97e37fb 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -25,5 +25,63 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "lock": { + "lock": { + "name": "[%key:component::lock::title%]" + } + }, + "number": { + "maximum_charging_current": { + "name": "Maximum charging current" + } + }, + "sensor": { + "charging_power": { + "name": "Charging power" + }, + "max_available_power": { + "name": "Max available power" + }, + "charging_speed": { + "name": "Charging speed" + }, + "added_range": { + "name": "Added range" + }, + "added_energy": { + "name": "Added energy" + }, + "added_discharged_energy": { + "name": "Discharged energy" + }, + "cost": { + "name": "Cost" + }, + "state_of_charge": { + "name": "State of charge" + }, + "current_mode": { + "name": "Current mode" + }, + "depot_price": { + "name": "Depot price" + }, + "energy_price": { + "name": "Energy price" + }, + "status_description": { + "name": "Status description" + }, + "max_charging_current": { + "name": "Max charging current" + } + }, + "switch": { + "pause_resume": { + "name": "Pause/resume" + } + } } } diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 7a0736f59e7412..b101ffe1c099f9 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -21,7 +21,7 @@ SWITCH_TYPES: dict[str, SwitchEntityDescription] = { CHARGER_PAUSE_RESUME_KEY: SwitchEntityDescription( key=CHARGER_PAUSE_RESUME_KEY, - name="Pause/Resume", + translation_key="pause_resume", ), } @@ -32,7 +32,7 @@ async def async_setup_entry( """Create wallbox sensor entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [WallboxSwitch(coordinator, entry, SWITCH_TYPES[CHARGER_PAUSE_RESUME_KEY])] + [WallboxSwitch(coordinator, SWITCH_TYPES[CHARGER_PAUSE_RESUME_KEY])] ) @@ -42,13 +42,11 @@ class WallboxSwitch(WallboxEntity, SwitchEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, description: SwitchEntityDescription, ) -> None: """Initialize a Wallbox switch.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" @property diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index bc51a91364ce26..d3cf1af21a2828 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -7,6 +7,7 @@ from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import WAQIDataUpdateCoordinator @@ -17,6 +18,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up World Air Quality Index (WAQI) from a config entry.""" + await _migrate_unique_ids(hass, entry) + client = WAQIClient(session=async_get_clientsession(hass)) client.authenticate(entry.data[CONF_API_KEY]) @@ -35,3 +38,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def _migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Migrate pre-config flow unique ids.""" + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + for reg_entry in registry_entries: + if isinstance(reg_entry.unique_id, int): + entity_registry.async_update_entity( + reg_entry.entity_id, new_unique_id=f"{reg_entry.unique_id}_air_quality" + ) diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index b5f3a18b223e06..8404b4256789ca 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -1,6 +1,7 @@ """Config flow for World Air Quality Index (WAQI) integration.""" from __future__ import annotations +from collections.abc import Awaitable, Callable import logging from typing import Any @@ -18,25 +19,36 @@ CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, + CONF_METHOD, CONF_NAME, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.selector import LocationSelector +from homeassistant.helpers.selector import ( + LocationSelector, + SelectSelector, + SelectSelectorConfig, +) from homeassistant.helpers.typing import ConfigType from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER _LOGGER = logging.getLogger(__name__) +CONF_MAP = "map" + class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for World Air Quality Index (WAQI).""" VERSION = 1 + def __init__(self) -> None: + """Initialize config flow.""" + self.data: dict[str, Any] = {} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -47,13 +59,8 @@ async def async_step_user( session=async_get_clientsession(self.hass) ) as waqi_client: waqi_client.authenticate(user_input[CONF_API_KEY]) - location = user_input[CONF_LOCATION] try: - measuring_station: WAQIAirQuality = ( - await waqi_client.get_by_coordinates( - location[CONF_LATITUDE], location[CONF_LONGITUDE] - ) - ) + await waqi_client.get_by_ip() except WAQIAuthenticationError: errors["base"] = "invalid_auth" except WAQIConnectionError: @@ -62,36 +69,110 @@ async def async_step_user( _LOGGER.exception(exc) errors["base"] = "unknown" else: - await self.async_set_unique_id(str(measuring_station.station_id)) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=measuring_station.city.name, - data={ - CONF_API_KEY: user_input[CONF_API_KEY], - CONF_STATION_NUMBER: measuring_station.station_id, - }, - ) + self.data = user_input + if user_input[CONF_METHOD] == CONF_MAP: + return await self.async_step_map() + return await self.async_step_station_number() return self.async_show_form( step_id="user", - data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_METHOD): SelectSelector( + SelectSelectorConfig( + options=[CONF_MAP, CONF_STATION_NUMBER], + translation_key="method", + ) + ), + } + ), + errors=errors, + ) + + async def _async_base_step( + self, + step_id: str, + method: Callable[[WAQIClient, dict[str, Any]], Awaitable[WAQIAirQuality]], + data_schema: vol.Schema, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + errors: dict[str, str] = {} + if user_input is not None: + async with WAQIClient( + session=async_get_clientsession(self.hass) + ) as waqi_client: + waqi_client.authenticate(self.data[CONF_API_KEY]) + try: + measuring_station = await method(waqi_client, user_input) + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception(exc) + errors["base"] = "unknown" + else: + return await self._async_create_entry(measuring_station) + return self.async_show_form( + step_id=step_id, data_schema=data_schema, errors=errors + ) + + async def async_step_map( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add measuring station via map.""" + return await self._async_base_step( + CONF_MAP, + lambda waqi_client, data: waqi_client.get_by_coordinates( + data[CONF_LOCATION][CONF_LATITUDE], data[CONF_LOCATION][CONF_LONGITUDE] + ), + self.add_suggested_values_to_schema( vol.Schema( { - vol.Required(CONF_API_KEY): str, vol.Required( CONF_LOCATION, ): LocationSelector(), } ), - user_input - or { + { CONF_LOCATION: { CONF_LATITUDE: self.hass.config.latitude, CONF_LONGITUDE: self.hass.config.longitude, } }, ), - errors=errors, + user_input, + ) + + async def async_step_station_number( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add measuring station via station number.""" + return await self._async_base_step( + CONF_STATION_NUMBER, + lambda waqi_client, data: waqi_client.get_by_station_number( + data[CONF_STATION_NUMBER] + ), + vol.Schema( + { + vol.Required( + CONF_STATION_NUMBER, + ): int, + } + ), + user_input, + ) + + async def _async_create_entry( + self, measuring_station: WAQIAirQuality + ) -> FlowResult: + await self.async_set_unique_id(str(measuring_station.station_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=measuring_station.city.name, + data={ + CONF_API_KEY: self.data[CONF_API_KEY], + CONF_STATION_NUMBER: measuring_station.station_id, + }, ) async def async_step_import(self, import_config: ConfigType) -> FlowResult: diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index bf31fb570a8d59..7b6bd3b85926c6 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", - "loggers": ["waqiasync"], - "requirements": ["aiowaqi==0.2.1"] + "loggers": ["aiowaqi"], + "requirements": ["aiowaqi==1.1.1"] } diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 0ad295ca5af9a3..62170b329f490d 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -159,7 +159,7 @@ def __init__(self, coordinator: WAQIDataUpdateCoordinator) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._attr_name = f"WAQI {self.coordinator.data.city.name}" - self._attr_unique_id = str(coordinator.data.station_id) + self._attr_unique_id = f"{coordinator.data.station_id}_air_quality" @property def native_value(self) -> int | None: diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json index 4ceb911de9e94a..46031a3072beb7 100644 --- a/homeassistant/components/waqi/strings.json +++ b/homeassistant/components/waqi/strings.json @@ -2,10 +2,20 @@ "config": { "step": { "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "method": "How do you want to select a measuring station?" + } + }, + "map": { "description": "Select a location to get the closest measuring station.", "data": { - "location": "[%key:common::config_flow::data::location%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "location": "[%key:common::config_flow::data::location%]" + } + }, + "station_number": { + "data": { + "station_number": "Measuring station number" } } }, @@ -18,6 +28,14 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "selector": { + "method": { + "options": { + "map": "Select nearest from point on the map", + "station_number": "Enter a station number" + } + } + }, "issues": { "deprecated_yaml_import_issue_invalid_auth": { "title": "The World Air Quality Index YAML configuration import failed", diff --git a/homeassistant/components/weatherflow/__init__.py b/homeassistant/components/weatherflow/__init__.py new file mode 100644 index 00000000000000..c64450babe7d99 --- /dev/null +++ b/homeassistant/components/weatherflow/__init__.py @@ -0,0 +1,77 @@ +"""Get data from Smart Weather station via UDP.""" +from __future__ import annotations + +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener +from pyweatherflowudp.device import EVENT_LOAD_COMPLETE, WeatherFlowDevice +from pyweatherflowudp.errors import ListenerError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.start import async_at_started + +from .const import DOMAIN, LOGGER, format_dispatch_call + +PLATFORMS = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up WeatherFlow from a config entry.""" + + client = WeatherFlowListener() + + @callback + def _async_device_discovered(device: WeatherFlowDevice) -> None: + LOGGER.debug("Found a device: %s", device) + + @callback + def _async_add_device_if_started(device: WeatherFlowDevice): + async_at_started( + hass, + callback( + lambda _: async_dispatcher_send( + hass, format_dispatch_call(entry), device + ) + ), + ) + + entry.async_on_unload( + device.on( + EVENT_LOAD_COMPLETE, + lambda _: _async_add_device_if_started(device), + ) + ) + + entry.async_on_unload(client.on(EVENT_DEVICE_DISCOVERED, _async_device_discovered)) + + try: + await client.start_listening() + except ListenerError as ex: + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_handle_ha_shutdown(event: Event) -> None: + """Handle HA shutdown.""" + await client.stop_listening() + + entry.async_on_unload( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_handle_ha_shutdown) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + client: WeatherFlowListener = hass.data[DOMAIN].pop(entry.entry_id, None) + if client: + await client.stop_listening() + + return unload_ok diff --git a/homeassistant/components/weatherflow/config_flow.py b/homeassistant/components/weatherflow/config_flow.py new file mode 100644 index 00000000000000..5ce737810b0a61 --- /dev/null +++ b/homeassistant/components/weatherflow/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for WeatherFlow.""" +from __future__ import annotations + +import asyncio +from asyncio import Future +from asyncio.exceptions import CancelledError +from typing import Any + +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener +from pyweatherflowudp.errors import AddressInUseError, EndpointError, ListenerError + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + DOMAIN, + ERROR_MSG_ADDRESS_IN_USE, + ERROR_MSG_CANNOT_CONNECT, + ERROR_MSG_NO_DEVICE_FOUND, +) + + +async def _async_can_discover_devices() -> bool: + """Return if there are devices that can be discovered.""" + future_event: Future[None] = asyncio.get_running_loop().create_future() + + @callback + def _async_found(_): + """Handle a discovered device - only need to do this once so.""" + + if not future_event.done(): + future_event.set_result(None) + + async with WeatherFlowListener() as client, asyncio.timeout(10): + try: + client.on(EVENT_DEVICE_DISCOVERED, _async_found) + await future_event + except asyncio.TimeoutError: + return False + + return True + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for WeatherFlow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + + # Only allow a single instance of integration since the listener + # will pick up all devices on the network and we don't want to + # create multiple entries. + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + found = False + errors = {} + try: + found = await _async_can_discover_devices() + except AddressInUseError: + errors["base"] = ERROR_MSG_ADDRESS_IN_USE + except (ListenerError, EndpointError, CancelledError): + errors["base"] = ERROR_MSG_CANNOT_CONNECT + + if not found and not errors: + errors["base"] = ERROR_MSG_NO_DEVICE_FOUND + + if errors: + return self.async_show_form(step_id="user", errors=errors) + + return self.async_create_entry(title="WeatherFlow", data={}) diff --git a/homeassistant/components/weatherflow/const.py b/homeassistant/components/weatherflow/const.py new file mode 100644 index 00000000000000..fdacc6ef1ebc2e --- /dev/null +++ b/homeassistant/components/weatherflow/const.py @@ -0,0 +1,18 @@ +"""Constants for the WeatherFlow integration.""" + +import logging + +from homeassistant.config_entries import ConfigEntry + +DOMAIN = "weatherflow" +LOGGER = logging.getLogger(__package__) + + +def format_dispatch_call(config_entry: ConfigEntry) -> str: + """Construct a dispatch call from a ConfigEntry.""" + return f"{config_entry.domain}_{config_entry.entry_id}_add" + + +ERROR_MSG_ADDRESS_IN_USE = "address_in_use" +ERROR_MSG_CANNOT_CONNECT = "cannot_connect" +ERROR_MSG_NO_DEVICE_FOUND = "no_device_found" diff --git a/homeassistant/components/weatherflow/manifest.json b/homeassistant/components/weatherflow/manifest.json new file mode 100644 index 00000000000000..3c34250652dd67 --- /dev/null +++ b/homeassistant/components/weatherflow/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "weatherflow", + "name": "WeatherFlow", + "codeowners": ["@natekspencer", "@jeeftor"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/weatherflow", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["pyweatherflowudp"], + "requirements": ["pyweatherflowudp==1.4.3"] +} diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py new file mode 100644 index 00000000000000..dfc8e585f1bafa --- /dev/null +++ b/homeassistant/components/weatherflow/sensor.py @@ -0,0 +1,386 @@ +"""Sensors for the weatherflow integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum + +from pyweatherflowudp.const import EVENT_RAPID_WIND +from pyweatherflowudp.device import ( + EVENT_OBSERVATION, + EVENT_STATUS_UPDATE, + WeatherFlowDevice, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEGREE, + LIGHT_LUX, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UV_INDEX, + EntityCategory, + UnitOfElectricPotential, + UnitOfIrradiance, + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolumetricFlux, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import DOMAIN, LOGGER, format_dispatch_call + + +@dataclass +class WeatherFlowSensorRequiredKeysMixin: + """Mixin for required keys.""" + + raw_data_conv_fn: Callable[[WeatherFlowDevice], datetime | StateType] + + +def precipitation_raw_conversion_fn(raw_data: Enum): + """Parse parse precipitation type.""" + if raw_data.name.lower() == "unknown": + return None + return raw_data.name.lower() + + +@dataclass +class WeatherFlowSensorEntityDescription( + SensorEntityDescription, WeatherFlowSensorRequiredKeysMixin +): + """Describes WeatherFlow sensor entity.""" + + event_subscriptions: list[str] = field(default_factory=lambda: [EVENT_OBSERVATION]) + imperial_suggested_unit: None | str = None + + def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType: + """Return the parsed sensor value.""" + raw_sensor_data = getattr(device, self.key) + return self.raw_data_conv_fn(raw_sensor_data) + + +SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( + WeatherFlowSensorEntityDescription( + key="air_density", + translation_key="air_density", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + raw_data_conv_fn=lambda raw_data: raw_data.m * 1000000, + ), + WeatherFlowSensorEntityDescription( + key="air_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="dew_point_temperature", + translation_key="dew_point", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="feels_like_temperature", + translation_key="feels_like", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wet_bulb_temperature", + translation_key="wet_bulb_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="battery", + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_average_distance", + icon="mdi:lightning-bolt", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + translation_key="lightning_average_distance", + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_count", + translation_key="lightning_count", + icon="mdi:lightning-bolt", + state_class=SensorStateClass.TOTAL, + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="precipitation_type", + translation_key="precipitation_type", + device_class=SensorDeviceClass.ENUM, + options=["none", "rain", "hail", "rain_hail", "unknown"], + icon="mdi:weather-rainy", + raw_data_conv_fn=precipitation_raw_conversion_fn, + ), + WeatherFlowSensorEntityDescription( + key="rain_accumulation_previous_minute", + icon="mdi:weather-rainy", + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.PRECIPITATION, + imperial_suggested_unit=UnitOfPrecipitationDepth.INCHES, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="rain_rate", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + icon="mdi:weather-rainy", + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="relative_humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + event_subscriptions=[EVENT_STATUS_UPDATE], + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="station_pressure", + translation_key="station_pressure", + native_unit_of_measurement=UnitOfPressure.MBAR, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + imperial_suggested_unit=UnitOfPressure.INHG, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="solar_radiation", + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + device_class=SensorDeviceClass.IRRADIANCE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="up_since", + translation_key="uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + event_subscriptions=[EVENT_STATUS_UPDATE], + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="uv", + translation_key="uv_index", + native_unit_of_measurement=UV_INDEX, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="vapor_pressure", + translation_key="vapor_pressure", + native_unit_of_measurement=UnitOfPressure.MBAR, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + imperial_suggested_unit=UnitOfPressure.INHG, + suggested_display_precision=5, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + ## Wind Sensors + WeatherFlowSensorEntityDescription( + key="wind_gust", + translation_key="wind_gust", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_lull", + translation_key="wind_lull", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + icon="mdi:weather-windy", + event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_speed_average", + translation_key="wind_speed_average", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_direction", + translation_key="wind_direction", + icon="mdi:compass-outline", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_direction_average", + translation_key="wind_direction_average", + icon="mdi:compass-outline", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up WeatherFlow sensors using config entry.""" + + @callback + def async_add_sensor(device: WeatherFlowDevice) -> None: + """Add WeatherFlow sensor.""" + LOGGER.debug("Adding sensors for %s", device) + + sensors: list[WeatherFlowSensorEntity] = [ + WeatherFlowSensorEntity( + device=device, + description=description, + is_metric=(hass.config.units == METRIC_SYSTEM), + ) + for description in SENSORS + if hasattr(device, description.key) + ] + + async_add_entities(sensors) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + format_dispatch_call(config_entry), + async_add_sensor, + ) + ) + + +class WeatherFlowSensorEntity(SensorEntity): + """Defines a WeatherFlow sensor entity.""" + + entity_description: WeatherFlowSensorEntityDescription + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + device: WeatherFlowDevice, + description: WeatherFlowSensorEntityDescription, + is_metric: bool = True, + ) -> None: + """Initialize a WeatherFlow sensor entity.""" + self.device = device + self.entity_description = description + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.serial_number)}, + manufacturer="WeatherFlow", + model=device.model, + name=device.serial_number, + sw_version=device.firmware_revision, + ) + + self._attr_unique_id = f"{device.serial_number}_{description.key}" + + # In the case of the USA - we may want to have a suggested US unit which differs from the internal suggested units + if description.imperial_suggested_unit is not None and not is_metric: + self._attr_suggested_unit_of_measurement = ( + description.imperial_suggested_unit + ) + + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + if self.entity_description.state_class == SensorStateClass.TOTAL: + return self.device.last_report + return None + + @property + def native_value(self) -> datetime | StateType: + """Return the state of the sensor.""" + return self.entity_description.get_native_value(self.device) + + async def async_added_to_hass(self) -> None: + """Subscribe to events.""" + for event in self.entity_description.event_subscriptions: + self.async_on_remove( + self.device.on(event, lambda _: self.async_write_ha_state()) + ) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json new file mode 100644 index 00000000000000..8f7a98abe04dc7 --- /dev/null +++ b/homeassistant/components/weatherflow/strings.json @@ -0,0 +1,82 @@ +{ + "config": { + "step": { + "user": { + "title": "WeatherFlow discovery", + "description": "Unable to discover Tempest WeatherFlow devices. Click submit to try again.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "address_in_use": "Unable to open local UDP port 50222.", + "cannot_connect": "UDP discovery error." + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + }, + "entity": { + "sensor": { + "air_density": { + "name": "Air density" + }, + "dew_point": { + "name": "Dew point" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "feels_like": { + "name": "Feels like" + }, + "lightning_average_distance": { + "name": "Lightning average distance" + }, + "lightning_count": { + "name": "Lightning count" + }, + "precipitation_type": { + "name": "Precipitation type", + "state": { + "none": "None", + "rain": "Rain", + "hail": "Hail", + "rain_hail": "Rain and hail" + } + }, + "station_pressure": { + "name": "Air pressure" + }, + "uptime": { + "name": "Uptime" + }, + "uv_index": { + "name": "UV index" + }, + "vapor_pressure": { + "name": "Vapor pressure" + }, + "wet_bulb_temperature": { + "name": "Wet bulb temperature" + }, + "wind_speed_average": { + "name": "Wind speed average" + }, + "wind_direction": { + "name": "Wind direction" + }, + "wind_direction_average": { + "name": "Wind direction average" + }, + "wind_gust": { + "name": "Wind gust" + }, + "wind_lull": { + "name": "Wind lull" + } + } + } +} diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index 34a5d45ca1f3e5..d28a6ff33157db 100644 --- a/homeassistant/components/weatherkit/manifest.json +++ b/homeassistant/components/weatherkit/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherkit", "iot_class": "cloud_polling", - "requirements": ["apple_weatherkit==1.0.3"] + "requirements": ["apple_weatherkit==1.0.4"] } diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py index 07745680b010a5..ce997fa500f22a 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -134,6 +134,7 @@ class WeatherKitWeather( _attr_native_pressure_unit = UnitOfPressure.MBAR _attr_native_visibility_unit = UnitOfLength.KILOMETERS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_native_precipitation_unit = UnitOfLength.MILLIMETERS def __init__( self, diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 5e7337086392e0..44d32b0603c1b6 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -4,20 +4,25 @@ """ from __future__ import annotations -import asyncio +from collections.abc import Awaitable, Callable +from typing import Any +from aiohttp.hdrs import METH_HEAD, METH_POST from aiohttp.web import Request, Response import voluptuous as vol from withings_api.common import NotifyAppli -from homeassistant.components import webhook +from homeassistant.components import cloud from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) +from homeassistant.components.http import HomeAssistantView from homeassistant.components.webhook import ( - async_generate_id, - async_unregister as async_unregister_webhook, + async_generate_id as webhook_generate_id, + async_generate_url as webhook_generate_url, + async_register as webhook_register, + async_unregister as webhook_unregister, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -25,29 +30,26 @@ CONF_CLIENT_SECRET, CONF_TOKEN, CONF_WEBHOOK_ID, + EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType -from . import const -from .common import ( - async_get_data_manager, - async_remove_data_manager, - get_data_manager_by_webhook_id, - json_message_response, -) -from .const import CONF_USE_WEBHOOK, CONFIG, LOGGER +from .api import ConfigEntryWithingsApi +from .const import CONF_CLOUDHOOK_URL, CONF_PROFILES, CONF_USE_WEBHOOK, DOMAIN, LOGGER +from .coordinator import WithingsDataUpdateCoordinator -DOMAIN = const.DOMAIN PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( - cv.deprecated(const.CONF_PROFILES), + cv.deprecated(CONF_PROFILES), cv.deprecated(CONF_CLIENT_ID), cv.deprecated(CONF_CLIENT_SECRET), vol.Schema( @@ -56,8 +58,8 @@ vol.Optional(CONF_CLIENT_SECRET): vol.All( cv.string, vol.Length(min=1) ), - vol.Optional(const.CONF_USE_WEBHOOK, default=False): cv.boolean, - vol.Optional(const.CONF_PROFILES): vol.All( + vol.Optional(CONF_USE_WEBHOOK): cv.boolean, + vol.Optional(CONF_PROFILES): vol.All( cv.ensure_list, vol.Unique(), vol.Length(min=1), @@ -73,80 +75,110 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Withings component.""" - if not (conf := config.get(DOMAIN)): - # Apply the defaults. - conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] - hass.data[DOMAIN] = {const.CONFIG: conf} - return True - - hass.data[DOMAIN] = {const.CONFIG: conf} - # Setup the oauth2 config flow. - if CONF_CLIENT_ID in conf: - await async_import_client_credential( + if conf := config.get(DOMAIN): + async_create_issue( hass, - DOMAIN, - ClientCredential( - conf[CONF_CLIENT_ID], - conf[CONF_CLIENT_SECRET], - ), - ) - LOGGER.warning( - "Configuration of Withings integration OAuth2 credentials in YAML " - "is deprecated and will be removed in a future release; Your " - "existing OAuth Application Credentials have been imported into " - "the UI automatically and can be safely removed from your " - "configuration.yaml file" + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Withings", + }, ) + if CONF_CLIENT_ID in conf: + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET], + ), + ) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Withings from a config entry.""" - if CONF_USE_WEBHOOK not in entry.options: + if CONF_WEBHOOK_ID not in entry.data or entry.unique_id is None: new_data = entry.data.copy() - new_options = { - CONF_USE_WEBHOOK: new_data.get(CONF_USE_WEBHOOK, False), - } unique_id = str(entry.data[CONF_TOKEN]["userid"]) if CONF_WEBHOOK_ID not in new_data: - new_data[CONF_WEBHOOK_ID] = async_generate_id() + new_data[CONF_WEBHOOK_ID] = webhook_generate_id() hass.config_entries.async_update_entry( - entry, data=new_data, options=new_options, unique_id=unique_id + entry, data=new_data, unique_id=unique_id ) - use_webhook = hass.data[DOMAIN][CONFIG][CONF_USE_WEBHOOK] - if use_webhook is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]: - new_options = entry.options.copy() - new_options |= {CONF_USE_WEBHOOK: use_webhook} - hass.config_entries.async_update_entry(entry, options=new_options) - - data_manager = await async_get_data_manager(hass, entry) - - LOGGER.debug("Confirming %s is authenticated to withings", entry.title) - await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh() - - webhook.async_register( - hass, - const.DOMAIN, - "Withings notify", - data_manager.webhook_config.id, - async_webhook_handler, + + client = ConfigEntryWithingsApi( + hass=hass, + config_entry=entry, + implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ), ) + coordinator = WithingsDataUpdateCoordinator(hass, client) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + async def unregister_webhook( + _: Any, + ) -> None: + LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + await hass.data[DOMAIN][entry.entry_id].async_unsubscribe_webhooks() + + async def register_webhook( + _: Any, + ) -> None: + if cloud.async_active_subscription(hass): + webhook_url = await async_cloudhook_generate_url(hass, entry) + else: + webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) + + if not webhook_url.startswith("https://"): + LOGGER.warning( + "Webhook not registered - " + "https and port 443 is required to register the webhook" + ) + return - # Perform first webhook subscription check. - if data_manager.webhook_config.enabled: - data_manager.async_start_polling_webhook_subscriptions() + webhook_register( + hass, + DOMAIN, + "Withings", + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(coordinator), + ) - @callback - def async_call_later_callback(now) -> None: - hass.async_create_task( - data_manager.subscription_update_coordinator.async_refresh() - ) + await hass.data[DOMAIN][entry.entry_id].async_subscribe_webhooks(webhook_url) + LOGGER.debug("Register Withings webhook: %s", webhook_url) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + + async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: + if state is cloud.CloudConnectionState.CLOUD_CONNECTED: + await register_webhook(None) + + if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED: + await unregister_webhook(None) + async_call_later(hass, 30, register_webhook) - # Start subscription check in the background, outside this component's setup. - entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback)) + if cloud.async_active_subscription(hass): + if cloud.async_is_connected(hass): + await register_webhook(None) + cloud.async_listen_connection_change(hass, manage_cloudhook) + else: + async_at_started(hass, register_webhook) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -156,19 +188,11 @@ def async_call_later_callback(now) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Withings config entry.""" - data_manager = await async_get_data_manager(hass, entry) - data_manager.async_stop_polling_webhook_subscriptions() + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - async_unregister_webhook(hass, data_manager.webhook_config.id) - - await asyncio.gather( - data_manager.async_unsubscribe_webhook(), - hass.config_entries.async_unload_platforms(entry, PLATFORMS), - ) - - async_remove_data_manager(hass, entry) - - return True + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -176,44 +200,69 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -async def async_webhook_handler( - hass: HomeAssistant, webhook_id: str, request: Request -) -> Response | None: - """Handle webhooks calls.""" - # Handle http head calls to the path. - # When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request. - if request.method.upper() == "HEAD": - return Response() +async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: + """Generate the full URL for a webhook_id.""" + if CONF_CLOUDHOOK_URL not in entry.data: + webhook_url = await cloud.async_create_cloudhook( + hass, entry.data[CONF_WEBHOOK_ID] + ) + data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} + hass.config_entries.async_update_entry(entry, data=data) + return webhook_url + return str(entry.data[CONF_CLOUDHOOK_URL]) + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Cleanup when entry is removed.""" + if cloud.async_active_subscription(hass): + try: + LOGGER.debug( + "Removing Withings cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) + except cloud.CloudNotAvailable: + pass - if request.method.upper() != "POST": - return json_message_response("Invalid method", message_code=2) - # Handle http post calls to the path. - if not request.body_exists: - return json_message_response("No request body", message_code=12) +def json_message_response(message: str, message_code: int) -> Response: + """Produce common json output.""" + return HomeAssistantView.json({"message": message, "code": message_code}) - params = await request.post() - if "appli" not in params: - return json_message_response("Parameter appli not provided", message_code=20) +def get_webhook_handler( + coordinator: WithingsDataUpdateCoordinator, +) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: + """Return webhook handler.""" - try: - appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type] - except ValueError: - return json_message_response("Invalid appli provided", message_code=21) + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + # Handle http head calls to the path. + # When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request. + if request.method == METH_HEAD: + return Response() - data_manager = get_data_manager_by_webhook_id(hass, webhook_id) - if not data_manager: - LOGGER.error( - ( - "Webhook id %s not handled by data manager. This is a bug and should be" - " reported" - ), - webhook_id, - ) - return json_message_response("User not found", message_code=1) + if request.method != METH_POST: + return json_message_response("Invalid method", message_code=2) + + # Handle http post calls to the path. + if not request.body_exists: + return json_message_response("No request body", message_code=12) + + params = await request.post() + + if "appli" not in params: + return json_message_response( + "Parameter appli not provided", message_code=20 + ) + + try: + appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type] + except ValueError: + return json_message_response("Invalid appli provided", message_code=21) + + await coordinator.async_webhook_data_updated(appli) - # Run this in the background and return immediately. - hass.async_create_task(data_manager.async_webhook_data_updated(appli)) + return json_message_response("Success", message_code=0) - return json_message_response("Success", message_code=0) + return async_webhook_handler diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 976774f23b34f8..309ef45623f763 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -14,9 +14,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import UpdateType, async_get_data_manager -from .const import Measurement -from .entity import BaseWithingsSensor, WithingsEntityDescription +from .const import DOMAIN, Measurement +from .coordinator import WithingsDataUpdateCoordinator +from .entity import WithingsEntity, WithingsEntityDescription @dataclass @@ -34,7 +34,6 @@ class WithingsBinarySensorEntityDescription( measure_type=NotifyAppli.BED_IN, translation_key="in_bed", icon="mdi:bed", - update_type=UpdateType.WEBHOOK, device_class=BinarySensorDeviceClass.OCCUPANCY, ), ] @@ -46,17 +45,16 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - data_manager = await async_get_data_manager(hass, entry) + coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [ - WithingsHealthBinarySensor(data_manager, attribute) - for attribute in BINARY_SENSORS + WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS ] - async_add_entities(entities, True) + async_add_entities(entities) -class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity): +class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): """Implementation of a Withings sensor.""" entity_description: WithingsBinarySensorEntityDescription @@ -64,4 +62,4 @@ class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self._state_data + return self.coordinator.in_bed diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py deleted file mode 100644 index 5f0090ad9a6cfd..00000000000000 --- a/homeassistant/components/withings/common.py +++ /dev/null @@ -1,495 +0,0 @@ -"""Common code for Withings.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from dataclasses import dataclass -import datetime -from datetime import timedelta -from enum import IntEnum, StrEnum -from http import HTTPStatus -import re -from typing import Any - -from aiohttp.web import Response -from withings_api.common import ( - AuthFailedException, - GetSleepSummaryField, - MeasureGroupAttribs, - MeasureType, - MeasureTypes, - NotifyAppli, - UnauthorizedException, - query_measure_groups, -) - -from homeassistant.components import webhook -from homeassistant.components.http import HomeAssistantView -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util import dt as dt_util - -from . import const -from .api import ConfigEntryWithingsApi -from .const import LOGGER, Measurement - -NOT_AUTHENTICATED_ERROR = re.compile( - f"^{HTTPStatus.UNAUTHORIZED},.*", - re.IGNORECASE, -) -DATA_UPDATED_SIGNAL = "withings_entity_state_updated" -SUBSCRIBE_DELAY = datetime.timedelta(seconds=5) -UNSUBSCRIBE_DELAY = datetime.timedelta(seconds=1) - - -class UpdateType(StrEnum): - """Data update type.""" - - POLL = "poll" - WEBHOOK = "webhook" - - -@dataclass -class WebhookConfig: - """Config for a webhook.""" - - id: str - url: str - enabled: bool - - -WITHINGS_MEASURE_TYPE_MAP: dict[ - NotifyAppli | GetSleepSummaryField | MeasureType, Measurement -] = { - MeasureType.WEIGHT: Measurement.WEIGHT_KG, - MeasureType.FAT_MASS_WEIGHT: Measurement.FAT_MASS_KG, - MeasureType.FAT_FREE_MASS: Measurement.FAT_FREE_MASS_KG, - MeasureType.MUSCLE_MASS: Measurement.MUSCLE_MASS_KG, - MeasureType.BONE_MASS: Measurement.BONE_MASS_KG, - MeasureType.HEIGHT: Measurement.HEIGHT_M, - MeasureType.TEMPERATURE: Measurement.TEMP_C, - MeasureType.BODY_TEMPERATURE: Measurement.BODY_TEMP_C, - MeasureType.SKIN_TEMPERATURE: Measurement.SKIN_TEMP_C, - MeasureType.FAT_RATIO: Measurement.FAT_RATIO_PCT, - MeasureType.DIASTOLIC_BLOOD_PRESSURE: Measurement.DIASTOLIC_MMHG, - MeasureType.SYSTOLIC_BLOOD_PRESSURE: Measurement.SYSTOLIC_MMGH, - MeasureType.HEART_RATE: Measurement.HEART_PULSE_BPM, - MeasureType.SP02: Measurement.SPO2_PCT, - MeasureType.HYDRATION: Measurement.HYDRATION, - MeasureType.PULSE_WAVE_VELOCITY: Measurement.PWV, - GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY: ( - Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY - ), - GetSleepSummaryField.DEEP_SLEEP_DURATION: Measurement.SLEEP_DEEP_DURATION_SECONDS, - GetSleepSummaryField.DURATION_TO_SLEEP: Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, - GetSleepSummaryField.DURATION_TO_WAKEUP: ( - Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS - ), - GetSleepSummaryField.HR_AVERAGE: Measurement.SLEEP_HEART_RATE_AVERAGE, - GetSleepSummaryField.HR_MAX: Measurement.SLEEP_HEART_RATE_MAX, - GetSleepSummaryField.HR_MIN: Measurement.SLEEP_HEART_RATE_MIN, - GetSleepSummaryField.LIGHT_SLEEP_DURATION: Measurement.SLEEP_LIGHT_DURATION_SECONDS, - GetSleepSummaryField.REM_SLEEP_DURATION: Measurement.SLEEP_REM_DURATION_SECONDS, - GetSleepSummaryField.RR_AVERAGE: Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, - GetSleepSummaryField.RR_MAX: Measurement.SLEEP_RESPIRATORY_RATE_MAX, - GetSleepSummaryField.RR_MIN: Measurement.SLEEP_RESPIRATORY_RATE_MIN, - GetSleepSummaryField.SLEEP_SCORE: Measurement.SLEEP_SCORE, - GetSleepSummaryField.SNORING: Measurement.SLEEP_SNORING, - GetSleepSummaryField.SNORING_EPISODE_COUNT: Measurement.SLEEP_SNORING_EPISODE_COUNT, - GetSleepSummaryField.WAKEUP_COUNT: Measurement.SLEEP_WAKEUP_COUNT, - GetSleepSummaryField.WAKEUP_DURATION: Measurement.SLEEP_WAKEUP_DURATION_SECONDS, - NotifyAppli.BED_IN: Measurement.IN_BED, -} - - -def json_message_response(message: str, message_code: int) -> Response: - """Produce common json output.""" - return HomeAssistantView.json({"message": message, "code": message_code}) - - -class WebhookAvailability(IntEnum): - """Represents various statuses of webhook availability.""" - - SUCCESS = 0 - CONNECT_ERROR = 1 - HTTP_ERROR = 2 - NOT_WEBHOOK = 3 - - -class WebhookUpdateCoordinator: - """Coordinates webhook data updates across listeners.""" - - def __init__(self, hass: HomeAssistant, user_id: int) -> None: - """Initialize the object.""" - self._hass = hass - self._user_id = user_id - self._listeners: list[CALLBACK_TYPE] = [] - self.data: dict[Measurement, Any] = {} - - def async_add_listener(self, listener: CALLBACK_TYPE) -> Callable[[], None]: - """Add a listener.""" - self._listeners.append(listener) - - @callback - def remove_listener() -> None: - self.async_remove_listener(listener) - - return remove_listener - - def async_remove_listener(self, listener: CALLBACK_TYPE) -> None: - """Remove a listener.""" - self._listeners.remove(listener) - - def update_data(self, measurement: Measurement, value: Any) -> None: - """Update the data object and notify listeners the data has changed.""" - self.data[measurement] = value - self.notify_data_changed() - - def notify_data_changed(self) -> None: - """Notify all listeners the data has changed.""" - for listener in self._listeners: - listener() - - -class DataManager: - """Manage withing data.""" - - def __init__( - self, - hass: HomeAssistant, - api: ConfigEntryWithingsApi, - user_id: int, - webhook_config: WebhookConfig, - ) -> None: - """Initialize the data manager.""" - self._hass = hass - self._api = api - self._user_id = user_id - self._webhook_config = webhook_config - self._notify_subscribe_delay = SUBSCRIBE_DELAY - self._notify_unsubscribe_delay = UNSUBSCRIBE_DELAY - - self._is_available = True - self._cancel_interval_update_interval: CALLBACK_TYPE | None = None - self._cancel_configure_webhook_subscribe_interval: CALLBACK_TYPE | None = None - self._api_notification_id = f"withings_{self._user_id}" - - self.subscription_update_coordinator = DataUpdateCoordinator( - hass, - LOGGER, - name="subscription_update_coordinator", - update_interval=timedelta(minutes=120), - update_method=self.async_subscribe_webhook, - ) - self.poll_data_update_coordinator = DataUpdateCoordinator[ - dict[MeasureType, Any] | None - ]( - hass, - LOGGER, - name="poll_data_update_coordinator", - update_interval=timedelta(minutes=120) - if self._webhook_config.enabled - else timedelta(minutes=10), - update_method=self.async_get_all_data, - ) - self.webhook_update_coordinator = WebhookUpdateCoordinator( - self._hass, self._user_id - ) - self._cancel_subscription_update: Callable[[], None] | None = None - self._subscribe_webhook_run_count = 0 - - @property - def webhook_config(self) -> WebhookConfig: - """Get the webhook config.""" - return self._webhook_config - - @property - def user_id(self) -> int: - """Get the user_id of the authenticated user.""" - return self._user_id - - def async_start_polling_webhook_subscriptions(self) -> None: - """Start polling webhook subscriptions (if enabled) to reconcile their setup.""" - self.async_stop_polling_webhook_subscriptions() - - def empty_listener() -> None: - pass - - self._cancel_subscription_update = ( - self.subscription_update_coordinator.async_add_listener(empty_listener) - ) - - def async_stop_polling_webhook_subscriptions(self) -> None: - """Stop polling webhook subscriptions.""" - if self._cancel_subscription_update: - self._cancel_subscription_update() - self._cancel_subscription_update = None - - async def async_subscribe_webhook(self) -> None: - """Subscribe the webhook to withings data updates.""" - LOGGER.debug("Configuring withings webhook") - - # On first startup, perform a fresh re-subscribe. Withings stops pushing data - # if the webhook fails enough times but they don't remove the old subscription - # config. This ensures the subscription is setup correctly and they start - # pushing again. - if self._subscribe_webhook_run_count == 0: - LOGGER.debug("Refreshing withings webhook configs") - await self.async_unsubscribe_webhook() - self._subscribe_webhook_run_count += 1 - - # Get the current webhooks. - response = await self._api.async_notify_list() - - subscribed_applis = frozenset( - profile.appli - for profile in response.profiles - if profile.callbackurl == self._webhook_config.url - ) - - # Determine what subscriptions need to be created. - ignored_applis = frozenset({NotifyAppli.USER, NotifyAppli.UNKNOWN}) - to_add_applis = frozenset( - appli - for appli in NotifyAppli - if appli not in subscribed_applis and appli not in ignored_applis - ) - - # Subscribe to each one. - for appli in to_add_applis: - LOGGER.debug( - "Subscribing %s for %s in %s seconds", - self._webhook_config.url, - appli, - self._notify_subscribe_delay.total_seconds(), - ) - # Withings will HTTP HEAD the callback_url and needs some downtime - # between each call or there is a higher chance of failure. - await asyncio.sleep(self._notify_subscribe_delay.total_seconds()) - await self._api.async_notify_subscribe(self._webhook_config.url, appli) - - async def async_unsubscribe_webhook(self) -> None: - """Unsubscribe webhook from withings data updates.""" - # Get the current webhooks. - response = await self._api.async_notify_list() - - # Revoke subscriptions. - for profile in response.profiles: - LOGGER.debug( - "Unsubscribing %s for %s in %s seconds", - profile.callbackurl, - profile.appli, - self._notify_unsubscribe_delay.total_seconds(), - ) - # Quick calls to Withings can result in the service returning errors. - # Give them some time to cool down. - await asyncio.sleep(self._notify_subscribe_delay.total_seconds()) - await self._api.async_notify_revoke(profile.callbackurl, profile.appli) - - async def async_get_all_data(self) -> dict[MeasureType, Any] | None: - """Update all withings data.""" - try: - return { - **await self.async_get_measures(), - **await self.async_get_sleep_summary(), - } - except Exception as exception: - # User is not authenticated. - if isinstance( - exception, (UnauthorizedException, AuthFailedException) - ) or NOT_AUTHENTICATED_ERROR.match(str(exception)): - self._api.config_entry.async_start_reauth(self._hass) - return None - - raise exception - - async def async_get_measures(self) -> dict[Measurement, Any]: - """Get the measures data.""" - LOGGER.debug("Updating withings measures") - now = dt_util.utcnow() - startdate = now - datetime.timedelta(days=7) - - response = await self._api.async_measure_get_meas( - None, None, startdate, now, None, startdate - ) - - # Sort from oldest to newest. - groups = sorted( - query_measure_groups( - response, MeasureTypes.ANY, MeasureGroupAttribs.UNAMBIGUOUS - ), - key=lambda group: group.created.datetime, - reverse=False, - ) - - return { - WITHINGS_MEASURE_TYPE_MAP[measure.type]: round( - float(measure.value * pow(10, measure.unit)), 2 - ) - for group in groups - for measure in group.measures - if measure.type in WITHINGS_MEASURE_TYPE_MAP - } - - async def async_get_sleep_summary(self) -> dict[Measurement, Any]: - """Get the sleep summary data.""" - LOGGER.debug("Updating withing sleep summary") - now = dt_util.now() - yesterday = now - datetime.timedelta(days=1) - yesterday_noon = dt_util.start_of_local_day(yesterday) + datetime.timedelta( - hours=12 - ) - yesterday_noon_utc = dt_util.as_utc(yesterday_noon) - - response = await self._api.async_sleep_get_summary( - lastupdate=yesterday_noon_utc, - data_fields=[ - GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, - GetSleepSummaryField.DEEP_SLEEP_DURATION, - GetSleepSummaryField.DURATION_TO_SLEEP, - GetSleepSummaryField.DURATION_TO_WAKEUP, - GetSleepSummaryField.HR_AVERAGE, - GetSleepSummaryField.HR_MAX, - GetSleepSummaryField.HR_MIN, - GetSleepSummaryField.LIGHT_SLEEP_DURATION, - GetSleepSummaryField.REM_SLEEP_DURATION, - GetSleepSummaryField.RR_AVERAGE, - GetSleepSummaryField.RR_MAX, - GetSleepSummaryField.RR_MIN, - GetSleepSummaryField.SLEEP_SCORE, - GetSleepSummaryField.SNORING, - GetSleepSummaryField.SNORING_EPISODE_COUNT, - GetSleepSummaryField.WAKEUP_COUNT, - GetSleepSummaryField.WAKEUP_DURATION, - ], - ) - - # Set the default to empty lists. - raw_values: dict[GetSleepSummaryField, list[int]] = { - field: [] for field in GetSleepSummaryField - } - - # Collect the raw data. - for serie in response.series: - data = serie.data - - for field in GetSleepSummaryField: - raw_values[field].append(dict(data)[field.value]) - - values: dict[GetSleepSummaryField, float] = {} - - def average(data: list[int]) -> float: - return sum(data) / len(data) - - def set_value(field: GetSleepSummaryField, func: Callable) -> None: - non_nones = [ - value for value in raw_values.get(field, []) if value is not None - ] - values[field] = func(non_nones) if non_nones else None - - set_value(GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, average) - set_value(GetSleepSummaryField.DEEP_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.DURATION_TO_SLEEP, average) - set_value(GetSleepSummaryField.DURATION_TO_WAKEUP, average) - set_value(GetSleepSummaryField.HR_AVERAGE, average) - set_value(GetSleepSummaryField.HR_MAX, average) - set_value(GetSleepSummaryField.HR_MIN, average) - set_value(GetSleepSummaryField.LIGHT_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.REM_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.RR_AVERAGE, average) - set_value(GetSleepSummaryField.RR_MAX, average) - set_value(GetSleepSummaryField.RR_MIN, average) - set_value(GetSleepSummaryField.SLEEP_SCORE, max) - set_value(GetSleepSummaryField.SNORING, average) - set_value(GetSleepSummaryField.SNORING_EPISODE_COUNT, sum) - set_value(GetSleepSummaryField.WAKEUP_COUNT, sum) - set_value(GetSleepSummaryField.WAKEUP_DURATION, average) - - return { - WITHINGS_MEASURE_TYPE_MAP[field]: round(value, 4) - if value is not None - else None - for field, value in values.items() - } - - async def async_webhook_data_updated(self, data_category: NotifyAppli) -> None: - """Handle scenario when data is updated from a webook.""" - LOGGER.debug("Withings webhook triggered") - if data_category in { - NotifyAppli.WEIGHT, - NotifyAppli.CIRCULATORY, - NotifyAppli.SLEEP, - }: - await self.poll_data_update_coordinator.async_request_refresh() - - elif data_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: - self.webhook_update_coordinator.update_data( - Measurement.IN_BED, data_category == NotifyAppli.BED_IN - ) - - -async def async_get_data_manager( - hass: HomeAssistant, config_entry: ConfigEntry -) -> DataManager: - """Get the data manager for a config entry.""" - hass.data.setdefault(const.DOMAIN, {}) - hass.data[const.DOMAIN].setdefault(config_entry.entry_id, {}) - config_entry_data = hass.data[const.DOMAIN][config_entry.entry_id] - - if const.DATA_MANAGER not in config_entry_data: - LOGGER.debug( - "Creating withings data manager for profile: %s", config_entry.title - ) - config_entry_data[const.DATA_MANAGER] = DataManager( - hass, - ConfigEntryWithingsApi( - hass=hass, - config_entry=config_entry, - implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, config_entry - ), - ), - config_entry.data["token"]["userid"], - WebhookConfig( - id=config_entry.data[CONF_WEBHOOK_ID], - url=webhook.async_generate_url( - hass, config_entry.data[CONF_WEBHOOK_ID] - ), - enabled=config_entry.options[const.CONF_USE_WEBHOOK], - ), - ) - - return config_entry_data[const.DATA_MANAGER] - - -def get_data_manager_by_webhook_id( - hass: HomeAssistant, webhook_id: str -) -> DataManager | None: - """Get a data manager by it's webhook id.""" - return next( - iter( - [ - data_manager - for data_manager in get_all_data_managers(hass) - if data_manager.webhook_config.id == webhook_id - ] - ), - None, - ) - - -def get_all_data_managers(hass: HomeAssistant) -> tuple[DataManager, ...]: - """Get all configured data managers.""" - return tuple( - config_entry_data[const.DATA_MANAGER] - for config_entry_data in hass.data[const.DOMAIN].values() - if const.DATA_MANAGER in config_entry_data - ) - - -def async_remove_data_manager(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Remove a data manager for a config entry.""" - del hass.data[const.DOMAIN][config_entry.entry_id][const.DATA_MANAGER] diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 4dd123468a01f1..35a4582ae4da3f 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -5,13 +5,11 @@ import logging from typing import Any -import voluptuous as vol from withings_api.common import AuthScope from homeassistant.components.webhook import async_generate_id -from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID -from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -27,14 +25,6 @@ class WithingsFlowHandler( reauth_entry: ConfigEntry | None = None - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> WithingsOptionsFlowHandler: - """Get the options flow for this handler.""" - return WithingsOptionsFlowHandler(config_entry) - @property def logger(self) -> logging.Logger: """Return logger.""" @@ -83,27 +73,9 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: ) if self.reauth_entry.unique_id == user_id: - self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + self.hass.config_entries.async_update_entry( + self.reauth_entry, data={**self.reauth_entry.data, **data} + ) return self.async_abort(reason="reauth_successful") return self.async_abort(reason="wrong_account") - - -class WithingsOptionsFlowHandler(OptionsFlowWithConfigEntry): - """Withings Options flow handler.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Initialize form.""" - if user_input is not None: - return self.async_create_entry( - data=user_input, - ) - return self.async_show_form( - step_id="init", - data_schema=self.add_suggested_values_to_schema( - vol.Schema({vol.Required(CONF_USE_WEBHOOK): bool}), - self.options, - ), - ) diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 545c7bfcb26a34..6129e0c4b2931e 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -5,6 +5,7 @@ DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" +CONF_CLOUDHOOK_URL = "cloudhook_url" DATA_MANAGER = "data_manager" diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py new file mode 100644 index 00000000000000..128d4e39193d7b --- /dev/null +++ b/homeassistant/components/withings/coordinator.py @@ -0,0 +1,266 @@ +"""Withings coordinator.""" +import asyncio +from collections.abc import Callable +from datetime import timedelta +from typing import Any + +from withings_api.common import ( + AuthFailedException, + GetSleepSummaryField, + MeasureGroupAttribs, + MeasureType, + MeasureTypes, + NotifyAppli, + UnauthorizedException, + query_measure_groups, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util + +from .api import ConfigEntryWithingsApi +from .const import LOGGER, Measurement + +SUBSCRIBE_DELAY = timedelta(seconds=5) +UNSUBSCRIBE_DELAY = timedelta(seconds=1) + +WITHINGS_MEASURE_TYPE_MAP: dict[ + NotifyAppli | GetSleepSummaryField | MeasureType, Measurement +] = { + MeasureType.WEIGHT: Measurement.WEIGHT_KG, + MeasureType.FAT_MASS_WEIGHT: Measurement.FAT_MASS_KG, + MeasureType.FAT_FREE_MASS: Measurement.FAT_FREE_MASS_KG, + MeasureType.MUSCLE_MASS: Measurement.MUSCLE_MASS_KG, + MeasureType.BONE_MASS: Measurement.BONE_MASS_KG, + MeasureType.HEIGHT: Measurement.HEIGHT_M, + MeasureType.TEMPERATURE: Measurement.TEMP_C, + MeasureType.BODY_TEMPERATURE: Measurement.BODY_TEMP_C, + MeasureType.SKIN_TEMPERATURE: Measurement.SKIN_TEMP_C, + MeasureType.FAT_RATIO: Measurement.FAT_RATIO_PCT, + MeasureType.DIASTOLIC_BLOOD_PRESSURE: Measurement.DIASTOLIC_MMHG, + MeasureType.SYSTOLIC_BLOOD_PRESSURE: Measurement.SYSTOLIC_MMGH, + MeasureType.HEART_RATE: Measurement.HEART_PULSE_BPM, + MeasureType.SP02: Measurement.SPO2_PCT, + MeasureType.HYDRATION: Measurement.HYDRATION, + MeasureType.PULSE_WAVE_VELOCITY: Measurement.PWV, + GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY: ( + Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY + ), + GetSleepSummaryField.DEEP_SLEEP_DURATION: Measurement.SLEEP_DEEP_DURATION_SECONDS, + GetSleepSummaryField.DURATION_TO_SLEEP: Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, + GetSleepSummaryField.DURATION_TO_WAKEUP: ( + Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS + ), + GetSleepSummaryField.HR_AVERAGE: Measurement.SLEEP_HEART_RATE_AVERAGE, + GetSleepSummaryField.HR_MAX: Measurement.SLEEP_HEART_RATE_MAX, + GetSleepSummaryField.HR_MIN: Measurement.SLEEP_HEART_RATE_MIN, + GetSleepSummaryField.LIGHT_SLEEP_DURATION: Measurement.SLEEP_LIGHT_DURATION_SECONDS, + GetSleepSummaryField.REM_SLEEP_DURATION: Measurement.SLEEP_REM_DURATION_SECONDS, + GetSleepSummaryField.RR_AVERAGE: Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, + GetSleepSummaryField.RR_MAX: Measurement.SLEEP_RESPIRATORY_RATE_MAX, + GetSleepSummaryField.RR_MIN: Measurement.SLEEP_RESPIRATORY_RATE_MIN, + GetSleepSummaryField.SLEEP_SCORE: Measurement.SLEEP_SCORE, + GetSleepSummaryField.SNORING: Measurement.SLEEP_SNORING, + GetSleepSummaryField.SNORING_EPISODE_COUNT: Measurement.SLEEP_SNORING_EPISODE_COUNT, + GetSleepSummaryField.WAKEUP_COUNT: Measurement.SLEEP_WAKEUP_COUNT, + GetSleepSummaryField.WAKEUP_DURATION: Measurement.SLEEP_WAKEUP_DURATION_SECONDS, + NotifyAppli.BED_IN: Measurement.IN_BED, +} + +UPDATE_INTERVAL = timedelta(minutes=10) + + +class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]): + """Base coordinator.""" + + in_bed: bool | None = None + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, LOGGER, name="Withings", update_interval=UPDATE_INTERVAL) + self._client = client + + async def async_subscribe_webhooks(self, webhook_url: str) -> None: + """Subscribe to webhooks.""" + await self.async_unsubscribe_webhooks() + + current_webhooks = await self._client.async_notify_list() + + subscribed_notifications = frozenset( + profile.appli + for profile in current_webhooks.profiles + if profile.callbackurl == webhook_url + ) + + notification_to_subscribe = ( + set(NotifyAppli) + - subscribed_notifications + - {NotifyAppli.USER, NotifyAppli.UNKNOWN} + ) + + for notification in notification_to_subscribe: + LOGGER.debug( + "Subscribing %s for %s in %s seconds", + webhook_url, + notification, + SUBSCRIBE_DELAY.total_seconds(), + ) + # Withings will HTTP HEAD the callback_url and needs some downtime + # between each call or there is a higher chance of failure. + await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds()) + await self._client.async_notify_subscribe(webhook_url, notification) + self.update_interval = None + + async def async_unsubscribe_webhooks(self) -> None: + """Unsubscribe to webhooks.""" + current_webhooks = await self._client.async_notify_list() + + for webhook_configuration in current_webhooks.profiles: + LOGGER.debug( + "Unsubscribing %s for %s in %s seconds", + webhook_configuration.callbackurl, + webhook_configuration.appli, + UNSUBSCRIBE_DELAY.total_seconds(), + ) + # Quick calls to Withings can result in the service returning errors. + # Give them some time to cool down. + await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds()) + await self._client.async_notify_revoke( + webhook_configuration.callbackurl, webhook_configuration.appli + ) + self.update_interval = UPDATE_INTERVAL + + async def _async_update_data(self) -> dict[Measurement, Any]: + try: + measurements = await self._get_measurements() + sleep_summary = await self._get_sleep_summary() + except (UnauthorizedException, AuthFailedException) as exc: + raise ConfigEntryAuthFailed from exc + return { + **measurements, + **sleep_summary, + } + + async def _get_measurements(self) -> dict[Measurement, Any]: + LOGGER.debug("Updating withings measures") + now = dt_util.utcnow() + startdate = now - timedelta(days=7) + + response = await self._client.async_measure_get_meas( + None, None, startdate, now, None, startdate + ) + + # Sort from oldest to newest. + groups = sorted( + query_measure_groups( + response, MeasureTypes.ANY, MeasureGroupAttribs.UNAMBIGUOUS + ), + key=lambda group: group.created.datetime, + reverse=False, + ) + + return { + WITHINGS_MEASURE_TYPE_MAP[measure.type]: round( + float(measure.value * pow(10, measure.unit)), 2 + ) + for group in groups + for measure in group.measures + if measure.type in WITHINGS_MEASURE_TYPE_MAP + } + + async def _get_sleep_summary(self) -> dict[Measurement, Any]: + now = dt_util.now() + yesterday = now - timedelta(days=1) + yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12) + yesterday_noon_utc = dt_util.as_utc(yesterday_noon) + + response = await self._client.async_sleep_get_summary( + lastupdate=yesterday_noon_utc, + data_fields=[ + GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, + GetSleepSummaryField.DEEP_SLEEP_DURATION, + GetSleepSummaryField.DURATION_TO_SLEEP, + GetSleepSummaryField.DURATION_TO_WAKEUP, + GetSleepSummaryField.HR_AVERAGE, + GetSleepSummaryField.HR_MAX, + GetSleepSummaryField.HR_MIN, + GetSleepSummaryField.LIGHT_SLEEP_DURATION, + GetSleepSummaryField.REM_SLEEP_DURATION, + GetSleepSummaryField.RR_AVERAGE, + GetSleepSummaryField.RR_MAX, + GetSleepSummaryField.RR_MIN, + GetSleepSummaryField.SLEEP_SCORE, + GetSleepSummaryField.SNORING, + GetSleepSummaryField.SNORING_EPISODE_COUNT, + GetSleepSummaryField.WAKEUP_COUNT, + GetSleepSummaryField.WAKEUP_DURATION, + ], + ) + + # Set the default to empty lists. + raw_values: dict[GetSleepSummaryField, list[int]] = { + field: [] for field in GetSleepSummaryField + } + + # Collect the raw data. + for serie in response.series: + data = serie.data + + for field in GetSleepSummaryField: + raw_values[field].append(dict(data)[field.value]) + + values: dict[GetSleepSummaryField, float] = {} + + def average(data: list[int]) -> float: + return sum(data) / len(data) + + def set_value(field: GetSleepSummaryField, func: Callable) -> None: + non_nones = [ + value for value in raw_values.get(field, []) if value is not None + ] + values[field] = func(non_nones) if non_nones else None + + set_value(GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, average) + set_value(GetSleepSummaryField.DEEP_SLEEP_DURATION, sum) + set_value(GetSleepSummaryField.DURATION_TO_SLEEP, average) + set_value(GetSleepSummaryField.DURATION_TO_WAKEUP, average) + set_value(GetSleepSummaryField.HR_AVERAGE, average) + set_value(GetSleepSummaryField.HR_MAX, average) + set_value(GetSleepSummaryField.HR_MIN, average) + set_value(GetSleepSummaryField.LIGHT_SLEEP_DURATION, sum) + set_value(GetSleepSummaryField.REM_SLEEP_DURATION, sum) + set_value(GetSleepSummaryField.RR_AVERAGE, average) + set_value(GetSleepSummaryField.RR_MAX, average) + set_value(GetSleepSummaryField.RR_MIN, average) + set_value(GetSleepSummaryField.SLEEP_SCORE, max) + set_value(GetSleepSummaryField.SNORING, average) + set_value(GetSleepSummaryField.SNORING_EPISODE_COUNT, sum) + set_value(GetSleepSummaryField.WAKEUP_COUNT, sum) + set_value(GetSleepSummaryField.WAKEUP_DURATION, average) + + return { + WITHINGS_MEASURE_TYPE_MAP[field]: round(value, 4) + if value is not None + else None + for field, value in values.items() + } + + async def async_webhook_data_updated( + self, notification_category: NotifyAppli + ) -> None: + """Update data when webhook is called.""" + LOGGER.debug("Withings webhook triggered") + if notification_category in { + NotifyAppli.WEIGHT, + NotifyAppli.CIRCULATORY, + NotifyAppli.SLEEP, + }: + await self.async_request_refresh() + + elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: + self.in_bed = notification_category == NotifyAppli.BED_IN + self.async_update_listeners() diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index f17d3ccf03c544..8005f97bfaa9f9 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -2,16 +2,15 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli -from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .common import DataManager, UpdateType from .const import DOMAIN, Measurement +from .coordinator import WithingsDataUpdateCoordinator @dataclass @@ -20,7 +19,6 @@ class WithingsEntityDescriptionMixin: measurement: Measurement measure_type: NotifyAppli | GetSleepSummaryField | MeasureType - update_type: UpdateType @dataclass @@ -28,72 +26,22 @@ class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixi """Immutable class for describing withings data.""" -class BaseWithingsSensor(Entity): - """Base class for withings sensors.""" +class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]): + """Base class for withings entities.""" - _attr_should_poll = False entity_description: WithingsEntityDescription _attr_has_entity_name = True def __init__( - self, data_manager: DataManager, description: WithingsEntityDescription + self, + coordinator: WithingsDataUpdateCoordinator, + description: WithingsEntityDescription, ) -> None: - """Initialize the Withings sensor.""" - self._data_manager = data_manager + """Initialize the Withings entity.""" + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = ( - f"withings_{data_manager.user_id}_{description.measurement.value}" - ) - self._state_data: Any | None = None + self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{description.measurement.value}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(data_manager.user_id))}, manufacturer="Withings" - ) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.entity_description.update_type == UpdateType.POLL: - return self._data_manager.poll_data_update_coordinator.last_update_success - - if self.entity_description.update_type == UpdateType.WEBHOOK: - return self._data_manager.webhook_config.enabled and ( - self.entity_description.measurement - in self._data_manager.webhook_update_coordinator.data - ) - - return True - - @callback - def _on_poll_data_updated(self) -> None: - self._update_state_data( - self._data_manager.poll_data_update_coordinator.data or {} + identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, + manufacturer="Withings", ) - - @callback - def _on_webhook_data_updated(self) -> None: - self._update_state_data( - self._data_manager.webhook_update_coordinator.data or {} - ) - - def _update_state_data(self, data: dict[Measurement, Any]) -> None: - """Update the state data.""" - self._state_data = data.get(self.entity_description.measurement) - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register update dispatcher.""" - if self.entity_description.update_type == UpdateType.POLL: - self.async_on_remove( - self._data_manager.poll_data_update_coordinator.async_add_listener( - self._on_poll_data_updated - ) - ) - self._on_poll_data_updated() - - elif self.entity_description.update_type == UpdateType.WEBHOOK: - self.async_on_remove( - self._data_manager.webhook_update_coordinator.async_add_listener( - self._on_webhook_data_updated - ) - ) - self._on_webhook_data_updated() diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 325205cb4d4c0f..edc8aab83b7fc1 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -1,6 +1,7 @@ { "domain": "withings", "name": "Withings", + "after_dependencies": ["cloud"], "codeowners": ["@vangorra", "@joostlek"], "config_flow": true, "dependencies": ["application_credentials", "http", "webhook"], diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index e8798adae2f059..42f5ac18f2f1d8 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -23,8 +23,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import UpdateType, async_get_data_manager from .const import ( + DOMAIN, SCORE_POINTS, UOM_BEATS_PER_MINUTE, UOM_BREATHS_PER_MINUTE, @@ -32,7 +32,8 @@ UOM_MMHG, Measurement, ) -from .entity import BaseWithingsSensor, WithingsEntityDescription +from .coordinator import WithingsDataUpdateCoordinator +from .entity import WithingsEntity, WithingsEntityDescription @dataclass @@ -50,7 +51,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.FAT_MASS_KG.value, @@ -60,7 +60,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.FAT_FREE_MASS_KG.value, @@ -70,7 +69,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.MUSCLE_MASS_KG.value, @@ -80,7 +78,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.BONE_MASS_KG.value, @@ -90,7 +87,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.HEIGHT_M.value, @@ -101,7 +97,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.TEMP_C.value, @@ -110,7 +105,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.BODY_TEMP_C.value, @@ -120,7 +114,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SKIN_TEMP_C.value, @@ -130,7 +123,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.FAT_RATIO_PCT.value, @@ -139,7 +131,6 @@ class WithingsSensorEntityDescription( translation_key="fat_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.DIASTOLIC_MMHG.value, @@ -148,7 +139,6 @@ class WithingsSensorEntityDescription( translation_key="diastolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SYSTOLIC_MMGH.value, @@ -157,7 +147,6 @@ class WithingsSensorEntityDescription( translation_key="systolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.HEART_PULSE_BPM.value, @@ -167,7 +156,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SPO2_PCT.value, @@ -176,7 +164,6 @@ class WithingsSensorEntityDescription( translation_key="spo2", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.HYDRATION.value, @@ -188,7 +175,6 @@ class WithingsSensorEntityDescription( icon="mdi:water", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.PWV.value, @@ -198,7 +184,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value, @@ -207,7 +192,6 @@ class WithingsSensorEntityDescription( translation_key="breathing_disturbances_intensity", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value, @@ -219,7 +203,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value, @@ -231,7 +214,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value, @@ -243,7 +225,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_AVERAGE.value, @@ -254,7 +235,6 @@ class WithingsSensorEntityDescription( icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_MAX.value, @@ -266,7 +246,6 @@ class WithingsSensorEntityDescription( icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_MIN.value, @@ -277,7 +256,6 @@ class WithingsSensorEntityDescription( icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value, @@ -289,7 +267,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_REM_DURATION_SECONDS.value, @@ -301,7 +278,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value, @@ -311,7 +287,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value, @@ -321,7 +296,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value, @@ -331,7 +305,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_SCORE.value, @@ -342,7 +315,6 @@ class WithingsSensorEntityDescription( icon="mdi:medal", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_SNORING.value, @@ -351,7 +323,6 @@ class WithingsSensorEntityDescription( translation_key="snoring", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value, @@ -360,7 +331,6 @@ class WithingsSensorEntityDescription( translation_key="snoring_episode_count", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_WAKEUP_COUNT.value, @@ -371,7 +341,6 @@ class WithingsSensorEntityDescription( icon="mdi:sleep-off", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value, @@ -383,7 +352,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), ] @@ -394,14 +362,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - data_manager = await async_get_data_manager(hass, entry) + coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities = [WithingsHealthSensor(data_manager, attribute) for attribute in SENSORS] + async_add_entities(WithingsSensor(coordinator, attribute) for attribute in SENSORS) - async_add_entities(entities, True) - -class WithingsHealthSensor(BaseWithingsSensor, SensorEntity): +class WithingsSensor(WithingsEntity, SensorEntity): """Implementation of a Withings sensor.""" entity_description: WithingsSensorEntityDescription @@ -409,4 +375,12 @@ class WithingsHealthSensor(BaseWithingsSensor, SensorEntity): @property def native_value(self) -> None | str | int | float: """Return the state of the entity.""" - return self._state_data + return self.coordinator.data[self.entity_description.measurement] + + @property + def available(self) -> bool: + """Return if the sensor is available.""" + return ( + super().available + and self.entity_description.measurement in self.coordinator.data + ) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 22718b305ecefe..ea925f535e30b7 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -22,15 +22,6 @@ "default": "Successfully authenticated with Withings." } }, - "options": { - "step": { - "init": { - "data": { - "use_webhook": "Use webhooks" - } - } - } - }, "entity": { "binary_sensor": { "in_bed": { diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index c3bf7f2efd5d61..558e0aa9ecfb6a 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -6,8 +6,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import CONF_COUNTRY, CONF_PROVINCE, PLATFORMS +from .const import CONF_COUNTRY, CONF_PROVINCE, DOMAIN, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -17,9 +18,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: province: str | None = entry.options.get(CONF_PROVINCE) if country and country not in list_supported_countries(): + async_create_issue( + hass, + DOMAIN, + "bad_country", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.ERROR, + translation_key="bad_country", + translation_placeholders={"title": entry.title}, + data={"entry_id": entry.entry_id, "country": None}, + ) raise ConfigEntryError(f"Selected country {country} is not valid") if country and province and province not in list_supported_countries()[country]: + async_create_issue( + hass, + DOMAIN, + "bad_province", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.ERROR, + translation_key="bad_province", + translation_placeholders={CONF_COUNTRY: country, "title": entry.title}, + data={"entry_id": entry.entry_id, "country": country}, + ) raise ConfigEntryError( f"Selected province {province} for country {country} is not valid" ) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index b60346c3bbb570..5daea6ce1291d0 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -5,7 +5,6 @@ from typing import Any from holidays import ( - DateLike, HolidayBase, __version__ as python_holidays_version, country_holidays, @@ -45,6 +44,26 @@ ) +def validate_dates(holiday_list: list[str]) -> list[str]: + """Validate and adds to list of dates to add or remove.""" + calc_holidays: list[str] = [] + for add_date in holiday_list: + if add_date.find(",") > 0: + dates = add_date.split(",", maxsplit=1) + d1 = dt_util.parse_date(dates[0]) + d2 = dt_util.parse_date(dates[1]) + if d1 is None or d2 is None: + LOGGER.error("Incorrect dates in date range: %s", add_date) + continue + _range: timedelta = d2 - d1 + for i in range(_range.days + 1): + day = d1 + timedelta(days=i) + calc_holidays.append(day.strftime("%Y-%m-%d")) + continue + calc_holidays.append(add_date) + return calc_holidays + + def valid_country(value: Any) -> str: """Validate that the given country is supported.""" value = cv.string(value) @@ -119,7 +138,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Workday sensor.""" - add_holidays: list[DateLike] = entry.options[CONF_ADD_HOLIDAYS] + add_holidays: list[str] = entry.options[CONF_ADD_HOLIDAYS] remove_holidays: list[str] = entry.options[CONF_REMOVE_HOLIDAYS] country: str | None = entry.options.get(CONF_COUNTRY) days_offset: int = int(entry.options[CONF_OFFSET]) @@ -141,14 +160,17 @@ async def async_setup_entry( else: obj_holidays = HolidayBase() + calc_add_holidays: list[str] = validate_dates(add_holidays) + calc_remove_holidays: list[str] = validate_dates(remove_holidays) + # Add custom holidays try: - obj_holidays.append(add_holidays) + obj_holidays.append(calc_add_holidays) # type: ignore[arg-type] except ValueError as error: LOGGER.error("Could not add custom holidays: %s", error) # Remove holidays - for remove_holiday in remove_holidays: + for remove_holiday in calc_remove_holidays: try: # is this formatted as a date? if dt_util.parse_date(remove_holiday): diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index df74fff83e1de5..6be7e119876c2a 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -69,10 +69,24 @@ def add_province_to_schema( return vol.Schema({**DATA_SCHEMA_OPT.schema, **add_schema}) +def _is_valid_date_range(check_date: str, error: type[HomeAssistantError]) -> bool: + """Validate date range.""" + if check_date.find(",") > 0: + dates = check_date.split(",", maxsplit=1) + for date in dates: + if dt_util.parse_date(date) is None: + raise error("Incorrect date in range") + return True + return False + + def validate_custom_dates(user_input: dict[str, Any]) -> None: """Validate custom dates for add/remove holidays.""" for add_date in user_input[CONF_ADD_HOLIDAYS]: - if dt_util.parse_date(add_date) is None: + if ( + not _is_valid_date_range(add_date, AddDateRangeError) + and dt_util.parse_date(add_date) is None + ): raise AddDatesError("Incorrect date") year: int = dt_util.now().year @@ -88,9 +102,12 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: obj_holidays = HolidayBase(years=year) for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: - if dt_util.parse_date(remove_date) is None: - if obj_holidays.get_named(remove_date) == []: - raise RemoveDatesError("Incorrect date or name") + if ( + not _is_valid_date_range(remove_date, RemoveDateRangeError) + and dt_util.parse_date(remove_date) is None + and obj_holidays.get_named(remove_date) == [] + ): + raise RemoveDatesError("Incorrect date or name") DATA_SCHEMA_SETUP = vol.Schema( @@ -223,8 +240,12 @@ async def async_step_options( ) except AddDatesError: errors["add_holidays"] = "add_holiday_error" + except AddDateRangeError: + errors["add_holidays"] = "add_holiday_range_error" except RemoveDatesError: errors["remove_holidays"] = "remove_holiday_error" + except RemoveDateRangeError: + errors["remove_holidays"] = "remove_holiday_range_error" except NotImplementedError: self.async_abort(reason="incorrect_province") @@ -284,8 +305,12 @@ async def async_step_init( ) except AddDatesError: errors["add_holidays"] = "add_holiday_error" + except AddDateRangeError: + errors["add_holidays"] = "add_holiday_range_error" except RemoveDatesError: errors["remove_holidays"] = "remove_holiday_error" + except RemoveDateRangeError: + errors["remove_holidays"] = "remove_holiday_range_error" else: LOGGER.debug("abort_check in options with %s", combined_input) try: @@ -328,9 +353,17 @@ class AddDatesError(HomeAssistantError): """Exception for error adding dates.""" +class AddDateRangeError(HomeAssistantError): + """Exception for error adding dates.""" + + class RemoveDatesError(HomeAssistantError): """Exception for error removing dates.""" +class RemoveDateRangeError(HomeAssistantError): + """Exception for error removing dates.""" + + class CountryNotExist(HomeAssistantError): """Exception country does not exist error.""" diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py new file mode 100644 index 00000000000000..ff643ecc2cb806 --- /dev/null +++ b/homeassistant/components/workday/repairs.py @@ -0,0 +1,124 @@ +"""Repairs platform for the Workday integration.""" + +from __future__ import annotations + +from typing import Any, cast + +from holidays import list_supported_countries +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .config_flow import NONE_SENTINEL +from .const import CONF_PROVINCE + + +class CountryFixFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry, country: str | None) -> None: + """Create flow.""" + self.entry = entry + self.country: str | None = country + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + if self.country: + return await self.async_step_province() + return await self.async_step_country() + + async def async_step_country( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the country step of a fix flow.""" + if user_input is not None: + all_countries = list_supported_countries() + if not all_countries[user_input[CONF_COUNTRY]]: + options = dict(self.entry.options) + new_options = {**options, **user_input, CONF_PROVINCE: None} + self.hass.config_entries.async_update_entry( + self.entry, options=new_options + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + self.country = user_input[CONF_COUNTRY] + return await self.async_step_province() + + return self.async_show_form( + step_id="country", + data_schema=vol.Schema( + { + vol.Required(CONF_COUNTRY): SelectSelector( + SelectSelectorConfig( + options=sorted(list_supported_countries()), + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ), + description_placeholders={"title": self.entry.title}, + ) + + async def async_step_province( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the province step of a fix flow.""" + if user_input and user_input.get(CONF_PROVINCE): + if user_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: + user_input[CONF_PROVINCE] = None + options = dict(self.entry.options) + new_options = {**options, **user_input, CONF_COUNTRY: self.country} + self.hass.config_entries.async_update_entry(self.entry, options=new_options) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + assert self.country + country_provinces = list_supported_countries()[self.country] + return self.async_show_form( + step_id="province", + data_schema=vol.Schema( + { + vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector( + SelectSelectorConfig( + options=[NONE_SENTINEL, *country_provinces], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_PROVINCE, + ) + ), + } + ), + description_placeholders={ + CONF_COUNTRY: self.country, + "title": self.entry.title, + }, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, Any] | None, +) -> RepairsFlow: + """Create flow.""" + entry = None + if data and (entry_id := data.get("entry_id")): + entry_id = cast(str, entry_id) + entry = hass.config_entries.async_get_entry(entry_id) + + if data and entry: + # Country or province does not exist + return CountryFixFlow(entry, data.get("country")) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index b4bad4796bce25..a4c2baf31c8238 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -26,15 +26,17 @@ "excludes": "List of workdays to exclude", "days_offset": "Days offset", "workdays": "List of workdays", - "add_holidays": "Add custom holidays as YYYY-MM-DD", - "remove_holidays": "Remove holidays as YYYY-MM-DD or by using partial of name", + "add_holidays": "Add custom holidays as YYYY-MM-DD or as range using `,` as separator", + "remove_holidays": "Remove holidays as YYYY-MM-DD, as range using `,` as separator or by using partial of name", "province": "State, Territory, Province, Region of Country" } } }, "error": { "add_holiday_error": "Incorrect format on date (YYYY-MM-DD)", - "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found" + "add_holiday_range_error": "Incorrect format on date range (YYYY-MM-DD,YYYY-MM-DD)", + "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found", + "remove_holiday_range_error": "Incorrect format on date range (YYYY-MM-DD,YYYY-MM-DD)" } }, "options": { @@ -61,7 +63,9 @@ }, "error": { "add_holiday_error": "[%key:component::workday::config::error::add_holiday_error%]", + "add_holiday_range_error": "[%key:component::workday::config::error::add_holiday_range_error%]", "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]", + "remove_holiday_range_error": "[%key:component::workday::config::error::remove_holiday_range_error%]", "already_configured": "Service with this configuration already exist" } }, @@ -88,5 +92,48 @@ "holiday": "Holidays" } } + }, + "issues": { + "bad_country": { + "title": "Configured Country for {title} does not exist", + "fix_flow": { + "step": { + "country": { + "title": "Select country for {title}", + "description": "Select a country to use for your Workday sensor.", + "data": { + "country": "[%key:component::workday::config::step::user::data::country%]" + } + }, + "province": { + "title": "Select province for {title}", + "description": "Select a province in country {country} to use for your Workday sensor.", + "data": { + "province": "[%key:component::workday::config::step::options::data::province%]" + }, + "data_description": { + "province": "State, Territory, Province, Region of Country" + } + } + } + } + }, + "bad_province": { + "title": "Configured province in country {country} for {title} does not exist", + "fix_flow": { + "step": { + "province": { + "title": "[%key:component::workday::issues::bad_country::fix_flow::step::province::title%]", + "description": "[%key:component::workday::issues::bad_country::fix_flow::step::province::description%]", + "data": { + "province": "[%key:component::workday::config::step::options::data::province%]" + }, + "data_description": { + "province": "[%key:component::workday::issues::bad_country::fix_flow::step::province::data_description::province%]" + } + } + } + } + } } } diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 810092094d15af..ddb5407e1cea52 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.1.0"] + "requirements": ["wyoming==1.2.0"] } diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index 0e7fb3c4429370..d4cbd9b92635a8 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -5,7 +5,7 @@ from wyoming.audio import AudioChunk, AudioStart from wyoming.client import AsyncTcpClient -from wyoming.wake import Detection +from wyoming.wake import Detect, Detection from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry @@ -46,7 +46,7 @@ def __init__( wake_service = service.info.wake[0] self._supported_wake_words = [ - wake_word.WakeWord(ww_id=ww.name, name=ww.name) + wake_word.WakeWord(id=ww.name, name=ww.description or ww.name) for ww in wake_service.models ] self._attr_name = wake_service.name @@ -58,7 +58,7 @@ def supported_wake_words(self) -> list[wake_word.WakeWord]: return self._supported_wake_words async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect one or more wake words in an audio stream. @@ -72,6 +72,11 @@ async def next_chunk(): try: async with AsyncTcpClient(self.service.host, self.service.port) as client: + # Inform client which wake word we want to detect (None = default) + await client.write_event( + Detect(names=[wake_word_id] if wake_word_id else None).event() + ) + await client.write_event( AudioStart( rate=16000, @@ -98,10 +103,20 @@ async def next_chunk(): break if Detection.is_type(event.type): - # Successful detection + # Possible detection detection = Detection.from_event(event) _LOGGER.info(detection) + if wake_word_id and (detection.name != wake_word_id): + _LOGGER.warning( + "Expected wake word %s but got %s, skipping", + wake_word_id, + detection.name, + ) + wake_task = asyncio.create_task(client.read_event()) + pending.add(wake_task) + continue + # Retrieve queued audio queued_audio: list[tuple[bytes, int]] | None = None if audio_task in pending: @@ -111,7 +126,7 @@ async def next_chunk(): queued_audio = [audio_task.result()] return wake_word.DetectionResult( - ww_id=detection.name, + wake_word_id=detection.name, timestamp=detection.timestamp, queued_audio=queued_audio, ) diff --git a/homeassistant/components/yardian/services.yaml b/homeassistant/components/yardian/services.yaml new file mode 100644 index 00000000000000..a8d05133f51414 --- /dev/null +++ b/homeassistant/components/yardian/services.yaml @@ -0,0 +1,14 @@ +start_irrigation: + target: + entity: + integration: yardian + domain: switch + fields: + duration: + required: true + default: 6 + selector: + number: + min: 1 + max: 1440 + unit_of_measurement: "minutes" diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index 6577c99456c51d..f841f3d3ed1d59 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -16,5 +16,17 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "start_irrigation": { + "name": "Start irrigation", + "description": "Starts the irrigation.", + "fields": { + "duration": { + "name": "Duration", + "description": "Duration for the target to be turned on." + } + } + } } } diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py index af5703e0fd4097..8598e4a8732e67 100644 --- a/homeassistant/components/yardian/switch.py +++ b/homeassistant/components/yardian/switch.py @@ -3,15 +3,23 @@ from typing import Any +import voluptuous as vol + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_WATERING_DURATION, DOMAIN from .coordinator import YardianUpdateCoordinator +SERVICE_START_IRRIGATION = "start_irrigation" +SERVICE_SCHEMA_START_IRRIGATION = { + vol.Required("duration"): cv.positive_int, +} + async def async_setup_entry( hass: HomeAssistant, @@ -28,6 +36,13 @@ async def async_setup_entry( for i in range(len(coordinator.data.zones)) ) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_START_IRRIGATION, + SERVICE_SCHEMA_START_IRRIGATION, + "async_turn_on", + ) + class YardianSwitch(CoordinatorEntity[YardianUpdateCoordinator], SwitchEntity): """Representation of a Yardian switch.""" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index e510a58b3e7d4c..ecb8c1f35d218b 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.13", "async-upnp-client==0.35.1"], + "requirements": ["yeelight==0.7.13", "async-upnp-client==0.36.1"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index d81ed1dfaaa382..9898c6a3496c55 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.112.0"] + "requirements": ["zeroconf==0.115.0"] } diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index bd181d82a33005..08db98cff6f4ad 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -170,7 +170,7 @@ async def async_zha_shutdown(): try: await zha_gateway.async_initialize() - except Exception: # pylint: disable=broad-except + except Exception: if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: try: await repairs.warn_on_wrong_silabs_firmware( diff --git a/homeassistant/components/zha/core/cluster_handlers/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py index f31830f0bd8b86..9c74a14daa8080 100644 --- a/homeassistant/components/zha/core/cluster_handlers/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -369,12 +369,11 @@ async def async_configure(self): ieee = self.cluster.endpoint.device.application.state.node_info.ieee try: - res = await self.write_attributes_safe({"cie_addr": ieee}) + await self.write_attributes_safe({"cie_addr": ieee}) self.debug( - "wrote cie_addr: %s to '%s' cluster: %s", + "wrote cie_addr: %s to '%s' cluster", str(ieee), self._cluster.ep_attribute, - res[0], ) except HomeAssistantError as ex: self.debug( diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 3610cd4142553c..9ce3a3eb7db161 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,16 +21,17 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.4", + "bellows==0.36.5", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.103", + "zha-quirks==0.0.104", "zigpy-deconz==0.21.1", - "zigpy==0.57.1", - "zigpy-xbee==0.18.2", + "zigpy==0.57.2", + "zigpy-xbee==0.18.3", "zigpy-zigate==0.11.0", - "zigpy-znp==0.11.4", - "universal-silabs-flasher==0.0.14" + "zigpy-znp==0.11.5", + "universal-silabs-flasher==0.0.14", + "pyserial-asyncio-fast==0.11" ], "usb": [ { diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index b56298e36ba762..b9a266304060f5 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -5,6 +5,7 @@ from collections import defaultdict from collections.abc import Coroutine from contextlib import suppress +import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient @@ -29,6 +30,7 @@ ATTR_ENTITY_ID, CONF_URL, EVENT_HOMEASSISTANT_STOP, + EVENT_LOGGING_CHANGED, Platform, ) from homeassistant.core import Event, HomeAssistant, callback @@ -93,6 +95,7 @@ DATA_CLIENT, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, + LIB_LOGGER, LOGGER, USER_AGENT, ZWAVE_JS_NOTIFICATION_EVENT, @@ -105,6 +108,8 @@ async_discover_single_value, ) from .helpers import ( + async_disable_server_logging_if_needed, + async_enable_server_logging_if_needed, async_enable_statistics, get_device_id, get_device_id_ext, @@ -249,6 +254,24 @@ async def setup(self, driver: Driver) -> None: elif opted_in is False: await driver.async_disable_statistics() + async def handle_logging_changed(_: Event | None = None) -> None: + """Handle logging changed event.""" + if LIB_LOGGER.isEnabledFor(logging.DEBUG): + await async_enable_server_logging_if_needed( + self.hass, self.config_entry, driver + ) + else: + await async_disable_server_logging_if_needed( + self.hass, self.config_entry, driver + ) + + # Set up server logging on setup if needed + await handle_logging_changed() + + self.config_entry.async_on_unload( + self.hass.bus.async_listen(EVENT_LOGGING_CHANGED, handle_logging_changed) + ) + # Check for nodes that no longer exist and remove them stored_devices = dr.async_entries_for_config_entry( self.dev_reg, self.config_entry.entry_id @@ -741,6 +764,7 @@ def async_on_notification(self, event: dict[str, Any]) -> None: ATTR_DOMAIN: DOMAIN, ATTR_NODE_ID: notification.node.node_id, ATTR_HOME_ID: driver.controller.home_id, + ATTR_ENDPOINT: notification.endpoint_idx, ATTR_DEVICE_ID: device.id, ATTR_COMMAND_CLASS: notification.command_class, } @@ -901,6 +925,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = all(await asyncio.gather(*tasks)) if tasks else True + if hasattr(driver_events, "driver"): + await async_disable_server_logging_if_needed(hass, entry, driver_events.driver) if DATA_CLIENT_LISTEN_TASK in info: await disconnect_client(hass, entry) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index d93745f7a66647..8658dc1cc1f8f3 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -82,7 +82,6 @@ from .helpers import ( async_enable_statistics, async_get_node_from_device_id, - async_update_data_collection_preference, get_device_id, ) @@ -411,15 +410,15 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_remove_node) websocket_api.async_register_command(hass, websocket_remove_failed_node) websocket_api.async_register_command(hass, websocket_replace_failed_node) - websocket_api.async_register_command(hass, websocket_begin_healing_network) + websocket_api.async_register_command(hass, websocket_begin_rebuilding_routes) websocket_api.async_register_command( - hass, websocket_subscribe_heal_network_progress + hass, websocket_subscribe_rebuild_routes_progress ) - websocket_api.async_register_command(hass, websocket_stop_healing_network) + websocket_api.async_register_command(hass, websocket_stop_rebuilding_routes) websocket_api.async_register_command(hass, websocket_refresh_node_info) websocket_api.async_register_command(hass, websocket_refresh_node_values) websocket_api.async_register_command(hass, websocket_refresh_node_cc_values) - websocket_api.async_register_command(hass, websocket_heal_node) + websocket_api.async_register_command(hass, websocket_rebuild_node_routes) websocket_api.async_register_command(hass, websocket_set_config_parameter) websocket_api.async_register_command(hass, websocket_get_config_parameters) websocket_api.async_register_command(hass, websocket_subscribe_log_updates) @@ -490,6 +489,7 @@ async def websocket_network_status( "state": "connected" if client.connected else "disconnected", "driver_version": client_version_info.driver_version, "server_version": client_version_info.server_version, + "server_logging_enabled": client.server_logging_enabled, }, "controller": { "home_id": controller.home_id, @@ -511,7 +511,7 @@ async def websocket_network_status( "supported_function_types": controller.supported_function_types, "suc_node_id": controller.suc_node_id, "supports_timers": controller.supports_timers, - "is_heal_network_active": controller.is_heal_network_active, + "is_rebuilding_routes": controller.is_rebuilding_routes, "inclusion_state": controller.inclusion_state, "rf_region": controller.rf_region, "status": controller.status, @@ -1379,14 +1379,14 @@ def node_removed(event: dict) -> None: @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/begin_healing_network", + vol.Required(TYPE): "zwave_js/begin_rebuilding_routes", vol.Required(ENTRY_ID): str, } ) @websocket_api.async_response @async_handle_failed_command @async_get_entry -async def websocket_begin_healing_network( +async def websocket_begin_rebuilding_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1394,10 +1394,10 @@ async def websocket_begin_healing_network( client: Client, driver: Driver, ) -> None: - """Begin healing the Z-Wave network.""" + """Begin rebuilding Z-Wave routes.""" controller = driver.controller - result = await controller.async_begin_healing_network() + result = await controller.async_begin_rebuilding_routes() connection.send_result( msg[ID], result, @@ -1407,13 +1407,13 @@ async def websocket_begin_healing_network( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/subscribe_heal_network_progress", + vol.Required(TYPE): "zwave_js/subscribe_rebuild_routes_progress", vol.Required(ENTRY_ID): str, } ) @websocket_api.async_response @async_get_entry -async def websocket_subscribe_heal_network_progress( +async def websocket_subscribe_rebuild_routes_progress( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1421,7 +1421,7 @@ async def websocket_subscribe_heal_network_progress( client: Client, driver: Driver, ) -> None: - """Subscribe to heal Z-Wave network status updates.""" + """Subscribe to rebuild Z-Wave routes status updates.""" controller = driver.controller @callback @@ -1434,30 +1434,39 @@ def async_cleanup() -> None: def forward_event(key: str, event: dict) -> None: connection.send_message( websocket_api.event_message( - msg[ID], {"event": event["event"], "heal_node_status": event[key]} + msg[ID], {"event": event["event"], "rebuild_routes_status": event[key]} ) ) connection.subscriptions[msg["id"]] = async_cleanup msg[DATA_UNSUBSCRIBE] = unsubs = [ - controller.on("heal network progress", partial(forward_event, "progress")), - controller.on("heal network done", partial(forward_event, "result")), + controller.on("rebuild routes progress", partial(forward_event, "progress")), + controller.on("rebuild routes done", partial(forward_event, "result")), ] - connection.send_result(msg[ID], controller.heal_network_progress) + if controller.rebuild_routes_progress: + connection.send_result( + msg[ID], + { + node.node_id: status + for node, status in controller.rebuild_routes_progress.items() + }, + ) + else: + connection.send_result(msg[ID], None) @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/stop_healing_network", + vol.Required(TYPE): "zwave_js/stop_rebuilding_routes", vol.Required(ENTRY_ID): str, } ) @websocket_api.async_response @async_handle_failed_command @async_get_entry -async def websocket_stop_healing_network( +async def websocket_stop_rebuilding_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1465,9 +1474,9 @@ async def websocket_stop_healing_network( client: Client, driver: Driver, ) -> None: - """Stop healing the Z-Wave network.""" + """Stop rebuilding Z-Wave routes.""" controller = driver.controller - result = await controller.async_stop_healing_network() + result = await controller.async_stop_rebuilding_routes() connection.send_result( msg[ID], result, @@ -1477,14 +1486,14 @@ async def websocket_stop_healing_network( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/heal_node", + vol.Required(TYPE): "zwave_js/rebuild_node_routes", vol.Required(DEVICE_ID): str, } ) @websocket_api.async_response @async_handle_failed_command @async_get_node -async def websocket_heal_node( +async def websocket_rebuild_node_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1495,7 +1504,7 @@ async def websocket_heal_node( assert driver is not None # The node comes from the driver instance. controller = driver.controller - result = await controller.async_heal_node(node) + result = await controller.async_rebuild_node_routes(node) connection.send_result( msg[ID], result, @@ -1866,7 +1875,10 @@ async def websocket_update_data_collection_preference( ) -> None: """Update preference for data collection and enable/disable collection.""" opted_in = msg[OPTED_IN] - async_update_data_collection_preference(hass, entry, opted_in) + if entry.data.get(CONF_DATA_COLLECTION_OPTED_IN) != opted_in: + new_data = entry.data.copy() + new_data[CONF_DATA_COLLECTION_OPTED_IN] = opted_in + hass.config_entries.async_update_entry(entry, data=new_data) if opted_in: await async_enable_statistics(driver) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 5ee8b3006037ee..34c6fa3363e445 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -31,10 +31,12 @@ DOMAIN = "zwave_js" DATA_CLIENT = "client" +DATA_OLD_SERVER_LOG_LEVEL = "old_server_log_level" EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" LOGGER = logging.getLogger(__package__) +LIB_LOGGER = logging.getLogger("zwave_js_server") # constants extra state attributes ATTR_RESERVED_VALUES = "reserved_values" # ConfigurationValue number entities diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index d54dc659be1e46..0a3f61fd824bd1 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -160,6 +160,8 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): writeable: bool | None = None # [optional] the value's states map must include ANY of these key/value pairs any_available_states: set[tuple[int, str]] | None = None + # [optional] the value's value must match this value + value: Any | None = None @dataclass @@ -378,6 +380,61 @@ class ZWaveDiscoverySchema: ) ], ), + # Fibaro Shutter Fibaro FGR223 + # Combine both switch_multilevel endpoints into shutter_tilt + # if operating mode (151) is set to venetian blind (2) + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter_tilt", + manufacturer_id={0x010F}, + product_id={0x1000, 0x1001}, + product_type={0x0303}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={1}, + type={ValueType.NUMBER}, + ), + data_template=CoverTiltDataTemplate( + current_tilt_value_id=ZwaveValueID( + property_=CURRENT_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + endpoint=2, + ), + target_tilt_value_id=ZwaveValueID( + property_=TARGET_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + endpoint=2, + ), + ), + required_values=[ + ZWaveValueDiscoverySchema( + command_class={CommandClass.CONFIGURATION}, + property={151}, + endpoint={0}, + value={2}, + ) + ], + ), + # Fibaro Shutter Fibaro FGR223 + # Disable endpoint 2 (slat), + # as these are either combined with endpoint one as shutter_tilt + # or it has no practical function. + # CC: Switch_Multilevel + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter", + manufacturer_id={0x010F}, + product_id={0x1000, 0x1001}, + product_type={0x0303}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={2}, + type={ValueType.NUMBER}, + ), + entity_registry_enabled_default=False, + ), # Fibaro Nice BiDi-ZWave (IBT4ZWAVE) ZWaveDiscoverySchema( platform=Platform.COVER, @@ -1236,6 +1293,9 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: ) ): return False + # check value + if schema.value is not None and value.value not in schema.value: + return False return True diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 3b1faa40fa825a..8774bcea73f90d 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -8,8 +8,14 @@ import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const import ( + LOG_LEVEL_MAP, + CommandClass, + ConfigurationValueType, + LogLevel, +) from zwave_js_server.model.driver import Driver +from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( ConfigurationValue, @@ -39,9 +45,10 @@ ATTR_ENDPOINT, ATTR_PROPERTY, ATTR_PROPERTY_KEY, - CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, + DATA_OLD_SERVER_LOG_LEVEL, DOMAIN, + LIB_LOGGER, LOGGER, ) @@ -125,17 +132,70 @@ def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: async def async_enable_statistics(driver: Driver) -> None: """Enable statistics on the driver.""" await driver.async_enable_statistics("Home Assistant", HA_VERSION) - await driver.async_enable_error_reporting() -@callback -def async_update_data_collection_preference( - hass: HomeAssistant, entry: ConfigEntry, preference: bool +async def async_enable_server_logging_if_needed( + hass: HomeAssistant, entry: ConfigEntry, driver: Driver ) -> None: - """Update data collection preference on config entry.""" - new_data = entry.data.copy() - new_data[CONF_DATA_COLLECTION_OPTED_IN] = preference - hass.config_entries.async_update_entry(entry, data=new_data) + """Enable logging of zwave-js-server in the lib.""" + # If lib log level is set to debug, we want to enable server logging. First we + # check if server log level is less verbose than library logging, and if so, set it + # to debug to match library logging. We will store the old server log level in + # hass.data so we can reset it later + if ( + not driver + or not driver.client.connected + or driver.client.server_logging_enabled + ): + return + + LOGGER.info("Enabling zwave-js-server logging") + if (curr_server_log_level := driver.log_config.level) and ( + LOG_LEVEL_MAP[curr_server_log_level] + ) > (lib_log_level := LIB_LOGGER.getEffectiveLevel()): + entry_data = hass.data[DOMAIN][entry.entry_id] + LOGGER.warning( + ( + "Server logging is set to %s and is currently less verbose " + "than library logging, setting server log level to %s to match" + ), + curr_server_log_level, + logging.getLevelName(lib_log_level), + ) + entry_data[DATA_OLD_SERVER_LOG_LEVEL] = curr_server_log_level + await driver.async_update_log_config(LogConfig(level=LogLevel.DEBUG)) + await driver.client.enable_server_logging() + LOGGER.info("Zwave-js-server logging is enabled") + + +async def async_disable_server_logging_if_needed( + hass: HomeAssistant, entry: ConfigEntry, driver: Driver +) -> None: + """Disable logging of zwave-js-server in the lib if still connected to server.""" + entry_data = hass.data[DOMAIN][entry.entry_id] + if ( + not driver + or not driver.client.connected + or not driver.client.server_logging_enabled + ): + return + LOGGER.info("Disabling zwave_js server logging") + if ( + DATA_OLD_SERVER_LOG_LEVEL in entry_data + and (old_server_log_level := entry_data.pop(DATA_OLD_SERVER_LOG_LEVEL)) + != driver.log_config.level + ): + LOGGER.info( + ( + "Server logging is currently set to %s as a result of server logging " + "being enabled. It is now being reset to %s" + ), + driver.log_config.level, + old_server_log_level, + ) + await driver.async_update_log_config(LogConfig(level=old_server_log_level)) + await driver.client.disable_server_logging() + LOGGER.info("Zwave-js-server logging is enabled") def get_valueless_base_unique_id(driver: Driver, node: ZwaveNode) -> str: diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index cfb2c239d8ef3c..3e8a5e4f7570e7 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,13 +3,13 @@ "name": "Z-Wave", "codeowners": ["@home-assistant/z-wave"], "config_flow": true, - "dependencies": ["usb", "http", "repairs", "websocket_api"], + "dependencies": ["http", "repairs", "usb", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/zwave_js", "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.51.3"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 5b7c157552ad3b..3dedd8bf370993 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -62,7 +62,12 @@ def as_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData: """Initialize the extra data from a dict.""" - if not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE]): + # If there was no firmware info stored, or if it's stale info, we don't restore + # anything. + if ( + not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE]) + or "normalizedVersion" not in firmware_dict + ): return cls(None) return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict)) @@ -267,9 +272,7 @@ async def async_install( ) try: - await self.driver.controller.async_firmware_update_ota( - self.node, firmware.files - ) + await self.driver.controller.async_firmware_update_ota(self.node, firmware) except BaseZwaveJSServerError as err: self._unsub_firmware_events_and_reset_progress() raise HomeAssistantError(err) from err diff --git a/homeassistant/const.py b/homeassistant/const.py index de968451af9f45..31256e502a47b6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,7 +6,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 10 +MINOR_VERSION: Final = 11 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" @@ -288,6 +288,7 @@ class Platform(StrEnum): EVENT_HOMEASSISTANT_STOP: Final = "homeassistant_stop" EVENT_HOMEASSISTANT_FINAL_WRITE: Final = "homeassistant_final_write" EVENT_LOGBOOK_ENTRY: Final = "logbook_entry" +EVENT_LOGGING_CHANGED: Final = "logging_changed" EVENT_SERVICE_REGISTERED: Final = "service_registered" EVENT_SERVICE_REMOVED: Final = "service_removed" EVENT_STATE_CHANGED: Final = "state_changed" diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 78c98bcc03d30d..8c9e3a57ddc232 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -18,6 +18,7 @@ "netatmo", "senz", "spotify", + "twitch", "withings", "xbox", "yolink", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 5784667bc675d6..c2b24b68d295e2 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -304,6 +304,10 @@ "domain": "led_ble", "local_name": "AP-*", }, + { + "domain": "medcom_ble", + "service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f", + }, { "domain": "melnor", "manufacturer_data_start": [ diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f229d753fecb00..ef22ac4f6533ad 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -23,6 +23,7 @@ "adguard", "advantage_air", "aemet", + "aftership", "agent_dvr", "airly", "airnow", @@ -82,6 +83,7 @@ "cloudflare", "co2signal", "coinbase", + "color_extractor", "comelit", "control4", "coolmaster", @@ -270,6 +272,7 @@ "matter", "mazda", "meater", + "medcom_ble", "melcloud", "melnor", "met", @@ -497,6 +500,7 @@ "twentemilieu", "twilio", "twinkly", + "twitch", "ukraine_alarm", "unifi", "unifiprotect", @@ -525,6 +529,7 @@ "waqi", "watttime", "waze_travel_time", + "weatherflow", "weatherkit", "webostv", "wemo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ef79e680ea28e1..1d9c2208ad0bd8 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -68,7 +68,7 @@ "aftership": { "name": "AfterShip", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "agent_dvr": { @@ -876,7 +876,7 @@ "color_extractor": { "name": "ColorExtractor", "integration_type": "hub", - "config_flow": false + "config_flow": true }, "comed": { "name": "Commonwealth Edison (ComEd)", @@ -3270,6 +3270,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "medcom_ble": { + "name": "Medcom Bluetooth", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "media_extractor": { "name": "Media Extractor", "integration_type": "hub", @@ -6015,7 +6021,7 @@ "twitch": { "name": "Twitch", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "twitter": { @@ -6341,6 +6347,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "weatherflow": { + "name": "WeatherFlow", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "webhook": { "name": "Webhook", "integration_type": "hub", diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 9d4d6e880f89a5..a3ddbf4cbca06e 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -777,9 +777,7 @@ async def resolve_dependencies(self) -> bool: return self._all_dependencies_resolved try: - dependencies = await _async_component_dependencies( - self.hass, self.domain, self, set(), set() - ) + dependencies = await _async_component_dependencies(self.hass, self) dependencies.discard(self.domain) self._all_dependencies = dependencies self._all_dependencies_resolved = True @@ -998,7 +996,7 @@ def __init__(self, domain: str) -> None: class CircularDependency(LoaderError): """Raised when a circular dependency is found when resolving components.""" - def __init__(self, from_domain: str, to_domain: str) -> None: + def __init__(self, from_domain: str | set[str], to_domain: str) -> None: """Initialize circular dependency error.""" super().__init__(f"Circular dependency detected: {from_domain} -> {to_domain}.") self.from_domain = from_domain @@ -1132,43 +1130,40 @@ def bind_hass(func: _CallableT) -> _CallableT: async def _async_component_dependencies( hass: HomeAssistant, - start_domain: str, integration: Integration, - loaded: set[str], - loading: set[str], ) -> set[str]: - """Recursive function to get component dependencies. - - Async friendly. - """ - domain = integration.domain - loading.add(domain) - - for dependency_domain in integration.dependencies: - # Check not already loaded - if dependency_domain in loaded: - continue - - # If we are already loading it, we have a circular dependency. - if dependency_domain in loading: - raise CircularDependency(domain, dependency_domain) - - loaded.add(dependency_domain) - - dep_integration = await async_get_integration(hass, dependency_domain) + """Get component dependencies.""" + loading = set() + loaded = set() + + async def component_dependencies_impl(integration: Integration) -> None: + """Recursively get component dependencies.""" + domain = integration.domain + loading.add(domain) + + for dependency_domain in integration.dependencies: + dep_integration = await async_get_integration(hass, dependency_domain) + + # If we are already loading it, we have a circular dependency. + # We have to check it here to make sure that every integration that + # depends on us, does not appear in our own after_dependencies. + if conflict := loading.intersection(dep_integration.after_dependencies): + raise CircularDependency(conflict, dependency_domain) + + # If we have already loaded it, no point doing it again. + if dependency_domain in loaded: + continue - if start_domain in dep_integration.after_dependencies: - raise CircularDependency(start_domain, dependency_domain) + # If we are already loading it, we have a circular dependency. + if dependency_domain in loading: + raise CircularDependency(dependency_domain, domain) - if dep_integration.dependencies: - dep_loaded = await _async_component_dependencies( - hass, start_domain, dep_integration, loaded, loading - ) + await component_dependencies_impl(dep_integration) - loaded.update(dep_loaded) + loading.remove(domain) + loaded.add(domain) - loaded.add(domain) - loading.remove(domain) + await component_dependencies_impl(integration) return loaded diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3017327d1dfcf1..d6f923f004723b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,8 +2,7 @@ aiodiscover==1.5.1 aiohttp==3.8.5 aiohttp_cors==0.7.0 astral==2.2 -async-timeout==4.0.3 -async-upnp-client==0.35.1 +async-upnp-client==0.36.1 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.8.0 @@ -15,14 +14,14 @@ bluetooth-auto-recovery==1.2.3 bluetooth-data-tools==1.12.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==41.0.3 -dbus-fast==2.9.0 +cryptography==41.0.4 +dbus-fast==2.11.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230911.0 +home-assistant-frontend==20230928.0 home-assistant-intents==2023.9.22 httpx==0.24.1 ifaddr==0.2.0 @@ -51,9 +50,9 @@ typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 -webrtcvad==2.0.10 +webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.112.0 +zeroconf==0.115.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -127,8 +126,9 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Require to avoid issues with decorators (#93904). v2 has breaking changes. -pydantic>=1.10.8,<2.0 +# Required to avoid breaking (#101042). +# v2 has breaking changes (#99218). +pydantic==1.10.12 # Breaks asyncio # https://github.com/pubnub/python/issues/130 @@ -173,3 +173,8 @@ pysnmp==1000000000.0.0 # The get-mac package has been replaced with getmac. Installing get-mac alongside getmac # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 + +# We want to skip the binary wheels for the 'charset-normalizer' packages. +# They are build with mypyc, but causes issues with our wheel builder. +# In order to do so, we need to constrain the version. +charset-normalizer==3.2.0 diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index 6e2cfc5325d1d6..ceb5e502221b55 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -66,6 +66,11 @@ def content(self) -> MockStreamReader: """Return the body as text.""" return MockStreamReader(self._content) + @property + def body_exists(self) -> bool: + """Return True if request has HTTP BODY, False otherwise.""" + return bool(self._text) + async def json(self, loads: JSONDecoder = json_loads) -> Any: """Return the body as JSON.""" return loads(self._text) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 2e31b212f1f345..5f18a7291302a9 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -203,7 +203,7 @@ def _parse_yaml( # If configuration file is empty YAML returns None # We convert that to an empty dict return ( - yaml.load(content, Loader=lambda stream: loader(stream, secrets)) + yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] or NodeDictClass() ) diff --git a/mypy.ini b/mypy.ini index 67390ef2ddf32a..c2ecac66946ba1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1902,6 +1902,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.london_underground.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lookin.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2362,6 +2372,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.poolsense.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.powerwall.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 7dfd584c59818f..6d6d3d126c8afc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0.dev0" +version = "2023.11.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -25,7 +25,6 @@ requires-python = ">=3.11.0" dependencies = [ "aiohttp==3.8.5", "astral==2.2", - "async-timeout==4.0.3", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", "awesomeversion==23.8.0", @@ -41,7 +40,7 @@ dependencies = [ "lru-dict==1.2.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==41.0.3", + "cryptography==41.0.4", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", "orjson==3.9.7", diff --git a/requirements.txt b/requirements.txt index 40f7584ca3172e..60eb2359ba538f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ # Home Assistant Core aiohttp==3.8.5 astral==2.2 -async-timeout==4.0.3 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 awesomeversion==23.8.0 @@ -16,7 +15,7 @@ ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 PyJWT==2.8.0 -cryptography==41.0.3 +cryptography==41.0.4 pyOpenSSL==23.2.0 orjson==3.9.7 packaging>=23.1 diff --git a/requirements_all.txt b/requirements_all.txt index 462d02e9e80623..c3c28dff73eb91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -97,7 +97,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.39.1 +PySwitchbot==0.40.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -186,7 +186,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.1 +aioairzone-cloud==0.2.4 # homeassistant.components.airzone aioairzone==0.6.8 @@ -210,13 +210,12 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.0.8 +aiocomelit==0.0.9 # homeassistant.components.dhcp aiodiscover==1.5.1 # homeassistant.components.dnsip -# homeassistant.components.minecraft_server aiodns==3.0.0 # homeassistant.components.eafm @@ -232,7 +231,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.5 +aioesphomeapi==17.0.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -250,14 +249,14 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.3 +aiohomekit==3.0.5 # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.6.2 +aiohue==4.7.0 # homeassistant.components.imap aioimaplib==1.0.1 @@ -364,16 +363,16 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==62 +aiounifi==63 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.3.0 +aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==0.2.1 +aiowaqi==1.1.1 # homeassistant.components.watttime aiowatttime==0.1.1 @@ -424,7 +423,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.3 +apple_weatherkit==1.0.4 # homeassistant.components.apprise apprise==1.5.0 @@ -459,7 +458,7 @@ async-interrupt==1.1.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.35.1 +async-upnp-client==0.36.1 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 @@ -513,7 +512,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.4 +bellows==0.36.5 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -646,7 +645,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.9.0 +dbus-fast==2.11.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -998,7 +997,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230911.0 +home-assistant-frontend==20230928.0 # homeassistant.components.conversation home-assistant-intents==2023.9.22 @@ -1127,7 +1126,7 @@ laundrify-aio==1.1.2 ld2410-ble==0.1.1 # homeassistant.components.led_ble -led-ble==1.0.0 +led-ble==1.0.1 # homeassistant.components.foscam libpyfoscam==1.0 @@ -1198,6 +1197,9 @@ mcstatus==11.0.0 # homeassistant.components.meater meater-python==0.0.8 +# homeassistant.components.medcom_ble +medcom-ble==0.1.1 + # homeassistant.components.melnor melnor-bluetooth==0.0.25 @@ -1381,7 +1383,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.34 +opower==0.0.35 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1497,10 +1499,10 @@ pvo==1.0.0 py-canary==0.5.3 # homeassistant.components.cpuspeed -py-cpuinfo==8.0.0 +py-cpuinfo==9.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.4 +py-dormakaba-dkey==1.0.5 # homeassistant.components.melissa py-melissa-climate==2.1.4 @@ -1536,7 +1538,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.8.4 +pyDuotecno==2023.9.0 # homeassistant.components.eight_sleep pyEight==0.3.2 @@ -1762,7 +1764,7 @@ pyinsteon==1.5.1 pyintesishome==1.8.0 # homeassistant.components.ipma -pyipma==3.0.6 +pyipma==3.0.7 # homeassistant.components.ipp pyipp==0.14.4 @@ -1869,9 +1871,6 @@ pymonoprice==0.4 # homeassistant.components.msteams pymsteams==0.1.12 -# homeassistant.components.myq -pymyq==3.1.4 - # homeassistant.components.mysensors pymysensors==0.24.0 @@ -2003,6 +2002,9 @@ pyschlage==2023.9.1 # homeassistant.components.sensibo pysensibo==1.0.35 +# homeassistant.components.zha +pyserial-asyncio-fast==0.11 + # homeassistant.components.serial # homeassistant.components.zha pyserial-asyncio==0.6 @@ -2146,6 +2148,9 @@ python-miio==0.5.12 # homeassistant.components.mpd python-mpd2==3.0.5 +# homeassistant.components.myq +python-myq==3.1.9 + # homeassistant.components.mystrom python-mystrom==2.2.0 @@ -2229,7 +2234,7 @@ pyvera==0.3.13 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.1 +pyvesync==2.1.10 # homeassistant.components.vizio pyvizio==0.1.61 @@ -2243,6 +2248,9 @@ pyvolumio==0.1.5 # homeassistant.components.waze_travel_time pywaze==0.5.0 +# homeassistant.components.weatherflow +pyweatherflowudp==1.4.3 + # homeassistant.components.html5 pywebpush==1.9.2 @@ -2608,7 +2616,7 @@ twentemilieu==1.0.0 twilio==6.32.0 # homeassistant.components.twitch -twitchAPI==3.10.0 +twitchAPI==4.0.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 @@ -2692,7 +2700,7 @@ waterfurnace==1.1.0 webexteamssdk==1.1.1 # homeassistant.components.assist_pipeline -webrtcvad==2.0.10 +webrtc-noise-gain==1.2.3 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 @@ -2716,7 +2724,7 @@ wled==0.16.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.1.0 +wyoming==1.2.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -2728,7 +2736,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.2.0 +xknxproject==3.3.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2767,7 +2775,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.7.6 +yt-dlp==2023.9.24 # homeassistant.components.zamg zamg==0.3.0 @@ -2776,13 +2784,13 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.112.0 +zeroconf==0.115.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.103 +zha-quirks==0.0.104 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2794,22 +2802,22 @@ ziggo-mediabox-xl==1.1.0 zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.2 +zigpy-xbee==0.18.3 # homeassistant.components.zha zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.11.4 +zigpy-znp==0.11.5 # homeassistant.components.zha -zigpy==0.57.1 +zigpy==0.57.2 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.3 +zwave-js-server-python==0.52.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index 2d0c256ac26283..d12ee6de114884 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,14 +7,14 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.15.4 +astroid==2.15.8 coverage==7.3.1 freezegun==1.2.2 mock-open==1.4.0 mypy==1.5.1 pre-commit==3.4.0 pydantic==1.10.12 -pylint==2.17.4 +pylint==2.17.6 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 @@ -33,23 +33,20 @@ requests-mock==1.11.0 respx==0.20.2 syrupy==4.5.0 tqdm==4.66.1 -types-aiofiles==22.1.0 +types-aiofiles==23.2.0.0 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 -types-backports==0.1.3 types-beautifulsoup4==4.12.0.6 types-caldav==1.3.0.0 types-chardet==0.1.5 -types-decorator==5.1.8.3 -types-enum34==1.1.8 -types-ipaddress==1.0.8 -types-paho-mqtt==1.6.0.6 -types-Pillow==10.0.0.2 -types-pkg-resources==0.1.3 -types-psutil==5.9.5 -types-python-dateutil==2.8.19.13 +types-decorator==5.1.8.4 +types-paho-mqtt==1.6.0.7 +types-Pillow==10.0.0.3 +types-protobuf==4.24.0.2 +types-psutil==5.9.5.16 +types-python-dateutil==2.8.19.14 types-python-slugify==0.1.2 -types-pytz==2023.3.0.0 -types-PyYAML==6.0.12.2 -types-requests==2.31.0.1 +types-pytz==2023.3.1.1 +types-PyYAML==6.0.12.12 +types-requests==2.31.0.3 types-xmltodict==0.13.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2ac88724baa48..cba22b8a54dc68 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.39.1 +PySwitchbot==0.40.0 # homeassistant.components.syncthru PySyncThru==0.7.10 @@ -167,7 +167,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.1 +aioairzone-cloud==0.2.4 # homeassistant.components.airzone aioairzone==0.6.8 @@ -191,13 +191,12 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.0.8 +aiocomelit==0.0.9 # homeassistant.components.dhcp aiodiscover==1.5.1 # homeassistant.components.dnsip -# homeassistant.components.minecraft_server aiodns==3.0.0 # homeassistant.components.eafm @@ -213,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.5 +aioesphomeapi==17.0.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -228,14 +227,14 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.3 +aiohomekit==3.0.5 # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.6.2 +aiohue==4.7.0 # homeassistant.components.imap aioimaplib==1.0.1 @@ -339,16 +338,16 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==62 +aiounifi==63 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.3.0 +aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==0.2.1 +aiowaqi==1.1.1 # homeassistant.components.watttime aiowatttime==0.1.1 @@ -390,7 +389,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.3 +apple_weatherkit==1.0.4 # homeassistant.components.apprise apprise==1.5.0 @@ -413,7 +412,7 @@ async-interrupt==1.1.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.35.1 +async-upnp-client==0.36.1 # homeassistant.components.sleepiq asyncsleepiq==1.3.7 @@ -437,7 +436,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.4 +bellows==0.36.5 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -529,7 +528,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.9.0 +dbus-fast==2.11.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -787,7 +786,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230911.0 +home-assistant-frontend==20230928.0 # homeassistant.components.conversation home-assistant-intents==2023.9.22 @@ -883,7 +882,7 @@ laundrify-aio==1.1.2 ld2410-ble==0.1.1 # homeassistant.components.led_ble -led-ble==1.0.0 +led-ble==1.0.1 # homeassistant.components.foscam libpyfoscam==1.0 @@ -930,6 +929,9 @@ mcstatus==11.0.0 # homeassistant.components.meater meater-python==0.0.8 +# homeassistant.components.medcom_ble +medcom-ble==0.1.1 + # homeassistant.components.melnor melnor-bluetooth==0.0.25 @@ -1059,7 +1061,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.34 +opower==0.0.35 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1142,10 +1144,10 @@ pvo==1.0.0 py-canary==0.5.3 # homeassistant.components.cpuspeed -py-cpuinfo==8.0.0 +py-cpuinfo==9.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.4 +py-dormakaba-dkey==1.0.5 # homeassistant.components.melissa py-melissa-climate==2.1.4 @@ -1169,7 +1171,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.8.4 +pyDuotecno==2023.9.0 # homeassistant.components.eight_sleep pyEight==0.3.2 @@ -1189,6 +1191,9 @@ pyW215==0.7.0 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 +# homeassistant.components.aftership +pyaftership==21.11.0 + # homeassistant.components.airnow pyairnow==1.2.1 @@ -1320,7 +1325,7 @@ pyicloud==1.0.0 pyinsteon==1.5.1 # homeassistant.components.ipma -pyipma==3.0.6 +pyipma==3.0.7 # homeassistant.components.ipp pyipp==0.14.4 @@ -1400,9 +1405,6 @@ pymodbus==3.5.2 # homeassistant.components.monoprice pymonoprice==0.4 -# homeassistant.components.myq -pymyq==3.1.4 - # homeassistant.components.mysensors pymysensors==0.24.0 @@ -1507,6 +1509,9 @@ pyschlage==2023.9.1 # homeassistant.components.sensibo pysensibo==1.0.35 +# homeassistant.components.zha +pyserial-asyncio-fast==0.11 + # homeassistant.components.serial # homeassistant.components.zha pyserial-asyncio==0.6 @@ -1593,6 +1598,9 @@ python-matter-server==3.7.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 +# homeassistant.components.myq +python-myq==3.1.9 + # homeassistant.components.mystrom python-mystrom==2.2.0 @@ -1655,7 +1663,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.13 # homeassistant.components.vesync -pyvesync==2.1.1 +pyvesync==2.1.10 # homeassistant.components.vizio pyvizio==0.1.61 @@ -1666,6 +1674,9 @@ pyvolumio==0.1.5 # homeassistant.components.waze_travel_time pywaze==0.5.0 +# homeassistant.components.weatherflow +pyweatherflowudp==1.4.3 + # homeassistant.components.html5 pywebpush==1.9.2 @@ -1926,7 +1937,7 @@ twentemilieu==1.0.0 twilio==6.32.0 # homeassistant.components.twitch -twitchAPI==3.10.0 +twitchAPI==4.0.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 @@ -1995,7 +2006,7 @@ wallbox==0.4.12 watchdog==2.3.1 # homeassistant.components.assist_pipeline -webrtcvad==2.0.10 +webrtc-noise-gain==1.2.3 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 @@ -2016,7 +2027,7 @@ wled==0.16.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.1.0 +wyoming==1.2.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -2028,7 +2039,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.2.0 +xknxproject==3.3.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2061,37 +2072,37 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.7.6 +yt-dlp==2023.9.24 # homeassistant.components.zamg zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.112.0 +zeroconf==0.115.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.103 +zha-quirks==0.0.104 # homeassistant.components.zha zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.2 +zigpy-xbee==0.18.3 # homeassistant.components.zha zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.11.4 +zigpy-znp==0.11.5 # homeassistant.components.zha -zigpy==0.57.1 +zigpy==0.57.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.3 +zwave-js-server-python==0.52.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e0e00ebc958618..e27b681f99859f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -128,8 +128,9 @@ # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Require to avoid issues with decorators (#93904). v2 has breaking changes. -pydantic>=1.10.8,<2.0 +# Required to avoid breaking (#101042). +# v2 has breaking changes (#99218). +pydantic==1.10.12 # Breaks asyncio # https://github.com/pubnub/python/issues/130 @@ -174,6 +175,11 @@ # The get-mac package has been replaced with getmac. Installing get-mac alongside getmac # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 + +# We want to skip the binary wheels for the 'charset-normalizer' packages. +# They are build with mypyc, but causes issues with our wheel builder. +# In order to do so, we need to constrain the version. +charset-normalizer==3.2.0 """ IGNORE_PRE_COMMIT_HOOK_ID = ( diff --git a/tests/common.py b/tests/common.py index af18640843d6ff..cd522aa3320958 100644 --- a/tests/common.py +++ b/tests/common.py @@ -67,7 +67,10 @@ restore_state as rs, storage, ) -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.setup import setup_component @@ -1443,3 +1446,17 @@ def async_get_persistent_notifications( ) -> dict[str, pn.Notification]: """Get the current persistent notifications.""" return pn._async_get_or_create_notifications(hass) + + +def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> None: + """Mock a signal the cloud disconnected.""" + from homeassistant.components.cloud import ( + SIGNAL_CLOUD_CONNECTION_STATE, + CloudConnectionState, + ) + + if connected: + state = CloudConnectionState.CLOUD_CONNECTED + else: + state = CloudConnectionState.CLOUD_DISCONNECTED + async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state) diff --git a/tests/components/aftership/__init__.py b/tests/components/aftership/__init__.py new file mode 100644 index 00000000000000..cdc39e5edfc858 --- /dev/null +++ b/tests/components/aftership/__init__.py @@ -0,0 +1 @@ +"""Tests for the AfterShip integration.""" diff --git a/tests/components/aftership/conftest.py b/tests/components/aftership/conftest.py new file mode 100644 index 00000000000000..e3fdc00bc30675 --- /dev/null +++ b/tests/components/aftership/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the AfterShip tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aftership.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/aftership/test_config_flow.py b/tests/components/aftership/test_config_flow.py new file mode 100644 index 00000000000000..2ac5919a5555ad --- /dev/null +++ b/tests/components/aftership/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test AfterShip config flow.""" +from unittest.mock import AsyncMock, patch + +from pyaftership import AfterShipException + +from homeassistant.components.aftership.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant, mock_setup_entry) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "mock-api-key", + } + + +async def test_flow_cannot_connect(hass: HomeAssistant, mock_setup_entry) -> None: + """Test handling invalid connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.side_effect = AfterShipException + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "mock-api-key", + } + + +async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: + """Test importing yaml config.""" + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: "yaml-api-key"}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "yaml-api-key", + } + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_import_flow_already_exists(hass: HomeAssistant) -> None: + """Test importing yaml config where entry already exists.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "yaml-api-key"}) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_API_KEY: "yaml-api-key"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 94e602ec03bd2b..1d1d060e80aaa3 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -79,11 +79,29 @@ 'id': 'aidoo1', 'installation': 'installation1', 'is-connected': True, - 'mode': None, + 'mode': 3, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), 'name': 'Bron', - 'power': None, + 'power': False, 'problems': False, 'temperature': 21.0, + 'temperature-setpoint': 22.0, + 'temperature-setpoint-cool-air': 22.0, + 'temperature-setpoint-hot-air': 22.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-max-auto-air': 30.0, + 'temperature-setpoint-max-cool-air': 30.0, + 'temperature-setpoint-max-hot-air': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-setpoint-min-auto-air': 18.0, + 'temperature-setpoint-min-cool-air': 18.0, + 'temperature-setpoint-min-hot-air': 16.0, 'temperature-step': 0.5, 'web-server': '11:22:33:44:55:67', 'ws-connected': True, @@ -91,19 +109,29 @@ }), 'groups': dict({ 'group1': dict({ - 'action': 6, + 'action': 1, 'active': True, 'available': True, 'humidity': 27, + 'id': 'group1', 'installation': 'installation1', - 'mode': 0, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'Group', 'num-devices': 2, - 'power': None, + 'power': True, 'systems': list([ 'system1', ]), 'temperature': 22.5, + 'temperature-setpoint': 24.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, 'temperature-step': 0.5, 'zones': list([ 'zone1', @@ -117,23 +145,68 @@ 'aidoo1', ]), 'available': True, + 'id': 'grp2', 'installation': 'installation1', - 'mode': 0, + 'mode': 3, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), 'name': 'Aidoo Group', 'num-devices': 1, - 'power': None, + 'power': False, 'temperature': 21.0, + 'temperature-setpoint': 22.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, 'temperature-step': 0.5, }), }), 'installations': dict({ 'installation1': dict({ + 'action': 1, + 'active': True, + 'aidoos': list([ + 'aidoo1', + ]), + 'available': True, + 'groups': list([ + 'group1', + 'grp2', + ]), + 'humidity': 27, 'id': 'installation1', + 'mode': 2, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), 'name': 'House', + 'num-devices': 3, + 'num-groups': 2, + 'power': True, + 'systems': list([ + 'system1', + ]), + 'temperature': 22.0, + 'temperature-setpoint': 23.3, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-step': 0.5, 'web-servers': list([ 'webserver1', '11:22:33:44:55:67', ]), + 'zones': list([ + 'zone1', + 'zone2', + ]), }), }), 'systems': dict({ @@ -147,7 +220,13 @@ 'id': 'system1', 'installation': 'installation1', 'is-connected': True, - 'mode': None, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'System 1', 'problems': True, 'system': 1, @@ -189,21 +268,47 @@ }), 'zones': dict({ 'zone1': dict({ - 'action': 6, + 'action': 1, 'active': True, 'available': True, 'humidity': 30, 'id': 'zone1', 'installation': 'installation1', 'is-connected': True, - 'master': None, - 'mode': None, + 'master': True, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'Salon', - 'power': None, + 'power': True, 'problems': False, 'system': 1, 'system-id': 'system1', 'temperature': 20.0, + 'temperature-setpoint': 24.0, + 'temperature-setpoint-cool-air': 24.0, + 'temperature-setpoint-dry-air': 24.0, + 'temperature-setpoint-hot-air': 20.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-max-cool-air': 30.0, + 'temperature-setpoint-max-dry-air': 30.0, + 'temperature-setpoint-max-emerheat-air': 30.0, + 'temperature-setpoint-max-hot-air': 30.0, + 'temperature-setpoint-max-stop-air': 30.0, + 'temperature-setpoint-max-vent-air': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-setpoint-min-cool-air': 18.0, + 'temperature-setpoint-min-dry-air': 18.0, + 'temperature-setpoint-min-emerheat-air': 15.0, + 'temperature-setpoint-min-hot-air': 15.0, + 'temperature-setpoint-min-stop-air': 15.0, + 'temperature-setpoint-min-vent-air': 15.0, + 'temperature-setpoint-stop-air': 24.0, + 'temperature-setpoint-vent-air': 24.0, 'temperature-step': 0.5, 'web-server': 'webserver1', 'ws-connected': True, @@ -217,14 +322,40 @@ 'id': 'zone2', 'installation': 'installation1', 'is-connected': True, - 'master': None, - 'mode': None, + 'master': False, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'Dormitorio', - 'power': None, + 'power': False, 'problems': False, 'system': 1, 'system-id': 'system1', 'temperature': 25.0, + 'temperature-setpoint': 24.0, + 'temperature-setpoint-cool-air': 24.0, + 'temperature-setpoint-dry-air': 24.0, + 'temperature-setpoint-hot-air': 20.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-max-cool-air': 30.0, + 'temperature-setpoint-max-dry-air': 30.0, + 'temperature-setpoint-max-emerheat-air': 30.0, + 'temperature-setpoint-max-hot-air': 30.0, + 'temperature-setpoint-max-stop-air': 30.0, + 'temperature-setpoint-max-vent-air': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-setpoint-min-cool-air': 18.0, + 'temperature-setpoint-min-dry-air': 18.0, + 'temperature-setpoint-min-emerheat-air': 15.0, + 'temperature-setpoint-min-hot-air': 15.0, + 'temperature-setpoint-min-stop-air': 15.0, + 'temperature-setpoint-min-vent-air': 15.0, + 'temperature-setpoint-stop-air': 24.0, + 'temperature-setpoint-vent-air': 24.0, 'temperature-step': 0.5, 'web-server': 'webserver1', 'ws-connected': True, diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py new file mode 100644 index 00000000000000..56c563a868052d --- /dev/null +++ b/tests/components/airzone_cloud/test_climate.py @@ -0,0 +1,435 @@ +"""The climate tests for the Airzone Cloud platform.""" +from unittest.mock import patch + +from aioairzone_cloud.exceptions import AirzoneCloudError +import pytest + +from homeassistant.components.airzone.const import API_TEMPERATURE_STEP +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_TARGET_TEMP_STEP, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .util import async_init_integration + + +async def test_airzone_create_climates(hass: HomeAssistant) -> None: + """Test creation of climates.""" + + await async_init_integration(hass) + + # Aidoos + state = hass.states.get("climate.bron") + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.0 + assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 + + # Groups + state = hass.states.get("climate.group") + assert state.state == HVACMode.COOL + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 27 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 22.5 + assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + + # Zones + state = hass.states.get("climate.dormitorio") + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 24 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25.0 + assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.COOL + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 30 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.0 + assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + + +async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: + """Test turning on/off.""" + + await async_init_integration(hass) + + # Aidoos + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "climate.bron", + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.state == HVACMode.HEAT + + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "climate.group", + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.COOL + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "climate.group", + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.OFF + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "climate.dormitorio", + }, + blocking=True, + ) + + state = hass.states.get("climate.dormitorio") + assert state.state == HVACMode.COOL + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "climate.salon", + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.OFF + + +async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: + """Test setting the HVAC mode.""" + + await async_init_integration(hass) + + # Aidoos + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.bron", + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.state == HVACMode.HEAT_COOL + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.bron", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.state == HVACMode.OFF + + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_HVAC_MODE: HVACMode.DRY, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.DRY + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.OFF + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.HEAT + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.OFF + + +async def test_airzone_climate_set_hvac_slave_error(hass: HomeAssistant) -> None: + """Test setting the HVAC mode for a slave zone.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.dormitorio", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + state = hass.states.get("climate.dormitorio") + assert state.state == HVACMode.COOL + + +async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: + """Test setting the target temperature.""" + + await async_init_integration(hass) + + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 + + +async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: + """Test error when setting the target temperature.""" + + await async_init_integration(hass) + + # Aidoos + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + side_effect=AirzoneCloudError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.bron", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 + + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + side_effect=AirzoneCloudError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + side_effect=AirzoneCloudError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 8fd7da06853c43..412f0df133765b 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import patch +from aioairzone_cloud.common import OperationMode from aioairzone_cloud.const import ( API_ACTIVE, API_AZ_AIDOO, @@ -24,8 +25,33 @@ API_IS_CONNECTED, API_LOCAL_TEMP, API_META, + API_MODE, + API_MODE_AVAIL, API_NAME, API_OLD_ID, + API_POWER, + API_RANGE_MAX_AIR, + API_RANGE_MIN_AIR, + API_RANGE_SP_MAX_AUTO_AIR, + API_RANGE_SP_MAX_COOL_AIR, + API_RANGE_SP_MAX_DRY_AIR, + API_RANGE_SP_MAX_EMERHEAT_AIR, + API_RANGE_SP_MAX_HOT_AIR, + API_RANGE_SP_MAX_STOP_AIR, + API_RANGE_SP_MAX_VENT_AIR, + API_RANGE_SP_MIN_AUTO_AIR, + API_RANGE_SP_MIN_COOL_AIR, + API_RANGE_SP_MIN_DRY_AIR, + API_RANGE_SP_MIN_EMERHEAT_AIR, + API_RANGE_SP_MIN_HOT_AIR, + API_RANGE_SP_MIN_STOP_AIR, + API_RANGE_SP_MIN_VENT_AIR, + API_SP_AIR_AUTO, + API_SP_AIR_COOL, + API_SP_AIR_DRY, + API_SP_AIR_HEAT, + API_SP_AIR_STOP, + API_SP_AIR_VENT, API_STAT_AP_MAC, API_STAT_CHANNEL, API_STAT_QUALITY, @@ -166,12 +192,29 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: False, API_ERRORS: [], + API_MODE: OperationMode.HEATING.value, + API_MODE_AVAIL: [ + OperationMode.AUTO.value, + OperationMode.COOLING.value, + OperationMode.HEATING.value, + OperationMode.VENTILATION.value, + OperationMode.DRY.value, + ], + API_SP_AIR_AUTO: {API_CELSIUS: 22, API_FAH: 72}, + API_SP_AIR_COOL: {API_CELSIUS: 22, API_FAH: 72}, + API_SP_AIR_HEAT: {API_CELSIUS: 22, API_FAH: 72}, + API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_AUTO_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_COOL_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_HOT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_MIN_AIR: {API_CELSIUS: 15, API_FAH: 59}, + API_RANGE_SP_MIN_AUTO_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_HOT_AIR: {API_CELSIUS: 16, API_FAH: 61}, + API_POWER: False, API_IS_CONNECTED: True, API_WS_CONNECTED: True, - API_LOCAL_TEMP: { - API_CELSIUS: 21, - API_FAH: 70, - }, + API_LOCAL_TEMP: {API_CELSIUS: 21, API_FAH: 70}, API_WARNINGS: [], } if device.get_id() == "system1": @@ -181,6 +224,13 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_OLD_ID: "error-id", }, ], + API_MODE: OperationMode.COOLING.value, + API_MODE_AVAIL: [ + OperationMode.COOLING.value, + OperationMode.HEATING.value, + OperationMode.VENTILATION.value, + OperationMode.DRY.value, + ], API_IS_CONNECTED: True, API_WS_CONNECTED: True, API_WARNINGS: [], @@ -189,24 +239,67 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: True, API_HUMIDITY: 30, + API_MODE: OperationMode.COOLING.value, + API_MODE_AVAIL: [ + OperationMode.COOLING.value, + OperationMode.HEATING.value, + OperationMode.VENTILATION.value, + OperationMode.DRY.value, + ], + API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_COOL_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_DRY_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_EMERHEAT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_HOT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_STOP_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_VENT_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_MIN_AIR: {API_CELSIUS: 15, API_FAH: 59}, + API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_DRY_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_EMERHEAT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_HOT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_STOP_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_VENT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_SP_AIR_COOL: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_DRY: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_HEAT: {API_CELSIUS: 20, API_FAH: 68}, + API_SP_AIR_VENT: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_STOP: {API_CELSIUS: 24, API_FAH: 75}, + API_POWER: True, API_IS_CONNECTED: True, API_WS_CONNECTED: True, - API_LOCAL_TEMP: { - API_FAH: 68, - API_CELSIUS: 20, - }, + API_LOCAL_TEMP: {API_FAH: 68, API_CELSIUS: 20}, API_WARNINGS: [], } if device.get_id() == "zone2": return { API_ACTIVE: False, API_HUMIDITY: 24, + API_MODE: OperationMode.COOLING.value, + API_MODE_AVAIL: [], + API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_COOL_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_DRY_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_EMERHEAT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_HOT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_STOP_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_VENT_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_MIN_AIR: {API_CELSIUS: 15, API_FAH: 59}, + API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_DRY_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_EMERHEAT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_HOT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_STOP_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_VENT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_SP_AIR_COOL: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_DRY: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_HEAT: {API_CELSIUS: 20, API_FAH: 68}, + API_SP_AIR_VENT: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_STOP: {API_CELSIUS: 24, API_FAH: 75}, + API_POWER: False, API_IS_CONNECTED: True, API_WS_CONNECTED: True, - API_LOCAL_TEMP: { - API_FAH: 77, - API_CELSIUS: 25, - }, + API_LOCAL_TEMP: {API_FAH: 77, API_CELSIUS: 25}, API_WARNINGS: [], } return None diff --git a/tests/components/anthemav/conftest.py b/tests/components/anthemav/conftest.py index 89dba9563d1174..7797f08872fe80 100644 --- a/tests/components/anthemav/conftest.py +++ b/tests/components/anthemav/conftest.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.anthemav.const import CONF_MODEL, DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -55,10 +55,10 @@ def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( domain=DOMAIN, + title="Anthem AV", data={ CONF_HOST: "1.1.1.1", CONF_PORT: 14999, - CONF_NAME: "Anthem AV", CONF_MAC: "00:00:00:00:00:01", CONF_MODEL: "MRX 520", }, diff --git a/tests/components/anthemav/test_config_flow.py b/tests/components/anthemav/test_config_flow.py index e62fb4ba52c47f..caa76006976a64 100644 --- a/tests/components/anthemav/test_config_flow.py +++ b/tests/components/anthemav/test_config_flow.py @@ -36,10 +36,10 @@ async def test_form_with_valid_connection( await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Anthem AV" assert result2["data"] == { "host": "1.1.1.1", "port": 14999, - "name": "Anthem AV", "mac": "00:00:00:00:00:01", "model": "MRX 520", } diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index d2ec3553cf0455..cde2666c1ead64 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -184,16 +184,18 @@ class MockWakeWordEntity(wake_word.WakeWordDetectionEntity): @property def supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" - return [wake_word.WakeWord(ww_id="test_ww", name="Test Wake Word")] + return [wake_word.WakeWord(id="test_ww", name="Test Wake Word")] async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" + if wake_word_id is None: + wake_word_id = self.supported_wake_words[0].id async for chunk, timestamp in stream: if chunk.startswith(b"wake word"): return wake_word.DetectionResult( - ww_id=self.supported_wake_words[0].ww_id, + wake_word_id=wake_word_id, timestamp=timestamp, queued_audio=[(b"queued audio", 0)], ) diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 7c1cf0e2b2d9b8..3f0582f2bfb59a 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -277,7 +277,7 @@ }), dict({ 'data': dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': , 'channel': , @@ -292,7 +292,7 @@ 'data': dict({ 'wake_word_output': dict({ 'timestamp': 2000, - 'ww_id': 'test_ww', + 'wake_word_id': 'test_ww', }), }), 'type': , @@ -311,18 +311,6 @@ }), 'type': , }), - dict({ - 'data': dict({ - 'timestamp': 0, - }), - 'type': , - }), - dict({ - 'data': dict({ - 'timestamp': 1500, - }), - 'type': , - }), dict({ 'data': dict({ 'stt_output': dict({ @@ -389,3 +377,38 @@ }), ]) # --- +# name: test_wake_word_detection_aborted + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'sample_rate': , + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'code': 'wake_word_detection_aborted', + 'message': '', + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 57fbe5f4908038..044e7758eb2396 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -173,6 +173,87 @@ 'message': 'No wake-word-detection provider for: wake_word.bad-entity-id', }) # --- +# name: test_audio_pipeline_with_enhancements + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 30, + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.1 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.2 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.3 + dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'homeassistant', + 'intent_input': 'test transcript', + 'language': 'en', + }) +# --- +# name: test_audio_pipeline_with_enhancements.4 + dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.5 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': "Sorry, I couldn't understand that", + 'voice': 'james_earl_jones', + }) +# --- +# name: test_audio_pipeline_with_enhancements.6 + dict({ + 'tts_output': dict({ + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.7 + None +# --- # name: test_audio_pipeline_with_wake_word dict({ 'language': 'en', @@ -185,7 +266,7 @@ # --- # name: test_audio_pipeline_with_wake_word.1 dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -200,7 +281,7 @@ 'wake_word_output': dict({ 'queued_audio': None, 'timestamp': 1000, - 'ww_id': 'test_ww', + 'wake_word_id': 'test_ww', }), }) # --- @@ -284,7 +365,7 @@ # --- # name: test_audio_pipeline_with_wake_word_no_timeout.1 dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -298,7 +379,7 @@ dict({ 'wake_word_output': dict({ 'timestamp': 0, - 'ww_id': 'test_ww', + 'wake_word_id': 'test_ww', }), }) # --- @@ -385,7 +466,7 @@ # --- # name: test_audio_pipeline_with_wake_word_timeout.1 dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 8687e2ad40c708..5258736c89f138 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -64,6 +64,9 @@ async def audio_data(): channel=stt.AudioChannels.CHANNEL_MONO, ), stt_stream=audio_data(), + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert process_events(events) == snapshot @@ -103,6 +106,8 @@ async def audio_data(): "tts_engine": "test", "tts_language": "en-US", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() @@ -124,6 +129,9 @@ async def audio_data(): ), stt_stream=audio_data(), pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert process_events(events) == snapshot @@ -163,6 +171,8 @@ async def audio_data(): "tts_engine": "test", "tts_language": "en-US", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() @@ -184,6 +194,9 @@ async def audio_data(): ), stt_stream=audio_data(), pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert process_events(events) == snapshot @@ -223,6 +236,8 @@ async def audio_data(): "tts_engine": "test", "tts_language": "en-AU", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() @@ -245,6 +260,9 @@ async def audio_data(): ), stt_stream=audio_data(), pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert not events @@ -306,44 +324,47 @@ async def test_pipeline_from_audio_stream_wake_word( # [0, 2, ...] wake_chunk_2 = bytes(it.islice(it.cycle(range(0, 256, 2)), BYTES_ONE_SECOND)) + bytes_per_chunk = int(0.01 * BYTES_ONE_SECOND) + async def audio_data(): - yield wake_chunk_1 # 1 second - yield wake_chunk_2 # 1 second + # 1 second in 10 ms chunks + i = 0 + while i < len(wake_chunk_1): + yield wake_chunk_1[i : i + bytes_per_chunk] + i += bytes_per_chunk + + # 1 second in 30 ms chunks + i = 0 + while i < len(wake_chunk_2): + yield wake_chunk_2[i : i + bytes_per_chunk] + i += bytes_per_chunk + yield b"wake word!" yield b"part1" yield b"part2" - yield b"end" yield b"" - def continue_stt(self, chunk): - # Ensure stt_vad_start event is triggered - self.in_command = True - - # Stop on fake end chunk to trigger stt_vad_end - return chunk != b"end" - - with patch( - "homeassistant.components.assist_pipeline.pipeline.VoiceCommandSegmenter.process", - continue_stt, - ): - await assist_pipeline.async_pipeline_from_audio_stream( - hass, - context=Context(), - event_callback=events.append, - stt_metadata=stt.SpeechMetadata( - language="", - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=audio_data(), - start_stage=assist_pipeline.PipelineStage.WAKE_WORD, - wake_word_settings=assist_pipeline.WakeWordSettings( - audio_seconds_to_buffer=1.5 - ), - ) + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), + ) assert process_events(events) == snapshot @@ -351,12 +372,14 @@ def continue_stt(self, chunk): # 2. queued audio (from mock wake word entity) # 3. part1 # 4. part2 - assert len(mock_stt_provider.received) == 4 + assert len(mock_stt_provider.received) > 3 - first_chunk = mock_stt_provider.received[0] + first_chunk = bytes( + [c_byte for c in mock_stt_provider.received[:-3] for c_byte in c] + ) assert first_chunk == wake_chunk_1[len(wake_chunk_1) // 2 :] + wake_chunk_2 - assert mock_stt_provider.received[1:] == [b"queued audio", b"part1", b"part2"] + assert mock_stt_provider.received[-3:] == [b"queued audio", b"part1", b"part2"] async def test_pipeline_save_audio( @@ -404,6 +427,9 @@ async def audio_data(): pipeline_id=pipeline.id, start_stage=assist_pipeline.PipelineStage.WAKE_WORD, end_stage=assist_pipeline.PipelineStage.STT, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) pipeline_dirs = list(temp_dir.iterdir()) @@ -537,3 +563,67 @@ async def audio_data(): start_stage=assist_pipeline.PipelineStage.WAKE_WORD, end_stage=assist_pipeline.PipelineStage.STT, ) + + +async def test_wake_word_detection_aborted( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, + snapshot: SnapshotAssertion, +) -> None: + """Test creating a pipeline from an audio stream with wake word.""" + + events: list[assist_pipeline.PipelineEvent] = [] + + async def audio_data(): + yield b"silence!" + yield b"wake word!" + yield b"part1" + yield b"part2" + yield b"" + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + conversation_id=None, + device_id=None, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output=None, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), + ), + ) + await pipeline_input.validate() + + updates = pipeline.to_json() + updates.pop("id") + await pipeline_store.async_update_item( + pipeline_id, + updates, + ) + await pipeline_input.execute() + + assert process_events(events) == snapshot diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 32468e3af91c1d..5a84f4c27162fe 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -8,15 +8,16 @@ from homeassistant.components.assist_pipeline.pipeline import ( STORAGE_KEY, STORAGE_VERSION, + STORAGE_VERSION_MINOR, Pipeline, PipelineData, PipelineStorageCollection, + PipelineStore, async_create_default_pipeline, async_get_pipeline, async_get_pipelines, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.storage import Store from homeassistant.setup import async_setup_component from . import MANY_LANGUAGES @@ -45,6 +46,8 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: "tts_engine": "tts_engine_1", "tts_language": "language_1", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", }, { "conversation_engine": "conversation_engine_2", @@ -56,6 +59,8 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: "tts_engine": "tts_engine_2", "tts_language": "language_2", "tts_voice": "The Voice", + "wake_word_entity": "wakeword_entity_2", + "wake_word_id": "wakeword_id_2", }, { "conversation_engine": "conversation_engine_3", @@ -67,6 +72,8 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: "tts_engine": None, "tts_language": None, "tts_voice": None, + "wake_word_entity": "wakeword_entity_3", + "wake_word_id": "wakeword_id_3", }, ] pipeline_ids = [] @@ -81,7 +88,11 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: await store1.async_delete_item(pipeline_ids[1]) assert len(store1.data) == 3 - store2 = PipelineStorageCollection(Store(hass, STORAGE_VERSION, STORAGE_KEY)) + store2 = PipelineStorageCollection( + PipelineStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) + ) await flush_store(store1.store) await store2.async_load() @@ -96,6 +107,71 @@ async def test_loading_pipelines_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored pipelines on start.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "minor_version": STORAGE_VERSION_MINOR, + "key": "assist_pipeline.pipelines", + "data": { + "items": [ + { + "conversation_engine": "conversation_engine_1", + "conversation_language": "language_1", + "id": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + "language": "language_1", + "name": "name_1", + "stt_engine": "stt_engine_1", + "stt_language": "language_1", + "tts_engine": "tts_engine_1", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", + }, + { + "conversation_engine": "conversation_engine_2", + "conversation_language": "language_2", + "id": "01GX8ZWBAQTKFQNK4W7Q4CTRCX", + "language": "language_2", + "name": "name_2", + "stt_engine": "stt_engine_2", + "stt_language": "language_2", + "tts_engine": "tts_engine_2", + "tts_language": "language_2", + "tts_voice": "The Voice", + "wake_word_entity": "wakeword_entity_2", + "wake_word_id": "wakeword_id_2", + }, + { + "conversation_engine": "conversation_engine_3", + "conversation_language": "language_3", + "id": "01GX8ZWBAQSV1HP3WGJPFWEJ8J", + "language": "language_3", + "name": "name_3", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": "wakeword_entity_3", + "wake_word_id": "wakeword_id_3", + }, + ], + "preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + }, + } + + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 3 + assert store.async_get_preferred_item() == "01GX8ZWBAQYWNB1XV3EXEZ75DY" + + +async def test_migrate_pipeline_store( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test loading stored pipelines from an older version.""" hass_storage[STORAGE_KEY] = { "version": 1, "minor_version": 1, @@ -173,6 +249,8 @@ async def test_create_default_pipeline( tts_engine="test", tts_language="en-US", tts_voice="james_earl_jones", + wake_word_entity=None, + wake_word_id=None, ) @@ -213,6 +291,8 @@ async def test_get_pipelines(hass: HomeAssistant) -> None: tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) ] @@ -258,6 +338,8 @@ async def test_default_pipeline_no_stt_tts( tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -318,6 +400,8 @@ async def test_default_pipeline( tts_engine="test", tts_language=tts_language, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -347,6 +431,8 @@ async def test_default_pipeline_unsupported_stt_language( tts_engine="test", tts_language="en-US", tts_voice="james_earl_jones", + wake_word_entity=None, + wake_word_id=None, ) @@ -376,6 +462,8 @@ async def test_default_pipeline_unsupported_tts_language( tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -424,4 +512,6 @@ async def test_default_pipeline_cloud( tts_engine="cloud", tts_language="en-US", tts_voice="james_earl_jones", + wake_word_entity=None, + wake_word_id=None, ) diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 1419eb5875079e..090c1034e4e2d8 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -70,6 +70,8 @@ async def pipeline_1( "tts_voice": None, "stt_engine": None, "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, } ) @@ -90,6 +92,8 @@ async def pipeline_2( "tts_voice": None, "stt_engine": None, "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, } ) diff --git a/tests/components/assist_pipeline/test_vad.py b/tests/components/assist_pipeline/test_vad.py index 4dc8c8f6197f96..57b567c49df07e 100644 --- a/tests/components/assist_pipeline/test_vad.py +++ b/tests/components/assist_pipeline/test_vad.py @@ -1,14 +1,15 @@ -"""Tests for webrtcvad voice command segmenter.""" +"""Tests for voice command segmenter.""" import itertools as it from unittest.mock import patch from homeassistant.components.assist_pipeline.vad import ( AudioBuffer, + VoiceActivityDetector, VoiceCommandSegmenter, chunk_samples, ) -_ONE_SECOND = 16000 * 2 # 16Khz 16-bit +_ONE_SECOND = 1.0 def test_silence() -> None: @@ -16,87 +17,85 @@ def test_silence() -> None: segmenter = VoiceCommandSegmenter() # True return value indicates voice command has not finished - assert segmenter.process(bytes(_ONE_SECOND * 3)) + assert segmenter.process(_ONE_SECOND * 3, False) def test_speech() -> None: """Test that silence + speech + silence triggers a voice command.""" - def is_speech(self, chunk, sample_rate): + def is_speech(chunk): """Anything non-zero is speech.""" return sum(chunk) > 0 - with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ): - segmenter = VoiceCommandSegmenter() + segmenter = VoiceCommandSegmenter() - # silence - assert segmenter.process(bytes(_ONE_SECOND)) + # silence + assert segmenter.process(_ONE_SECOND, False) - # "speech" - assert segmenter.process(bytes([255] * _ONE_SECOND)) + # "speech" + assert segmenter.process(_ONE_SECOND, True) - # silence - # False return value indicates voice command is finished - assert not segmenter.process(bytes(_ONE_SECOND)) + # silence + # False return value indicates voice command is finished + assert not segmenter.process(_ONE_SECOND, False) def test_audio_buffer() -> None: """Test audio buffer wrapping.""" - def is_speech(self, chunk, sample_rate): - """Disable VAD.""" - return False - - with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ): - segmenter = VoiceCommandSegmenter() - bytes_per_chunk = segmenter.vad_samples_per_chunk * 2 - - with patch.object( - segmenter, "_process_chunk", return_value=True - ) as mock_process: - # Partially fill audio buffer - half_chunk = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk // 2)) - segmenter.process(half_chunk) - - assert not mock_process.called - assert segmenter.audio_buffer == half_chunk - - # Fill and wrap with 1/4 chunk left over - three_quarters_chunk = bytes( - it.islice(it.cycle(range(256)), int(0.75 * bytes_per_chunk)) - ) - segmenter.process(three_quarters_chunk) - - assert mock_process.call_count == 1 - assert ( - segmenter.audio_buffer - == three_quarters_chunk[ - len(three_quarters_chunk) - (bytes_per_chunk // 4) : - ] - ) - assert ( - mock_process.call_args[0][0] - == half_chunk + three_quarters_chunk[: bytes_per_chunk // 2] - ) - - # Run 2 chunks through - segmenter.reset() - assert len(segmenter.audio_buffer) == 0 - - mock_process.reset_mock() - two_chunks = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk * 2)) - segmenter.process(two_chunks) - - assert mock_process.call_count == 2 - assert len(segmenter.audio_buffer) == 0 - assert mock_process.call_args_list[0][0][0] == two_chunks[:bytes_per_chunk] - assert mock_process.call_args_list[1][0][0] == two_chunks[bytes_per_chunk:] + class DisabledVad(VoiceActivityDetector): + def is_speech(self, chunk): + return False + + @property + def samples_per_chunk(self): + return 160 # 10 ms + + vad = DisabledVad() + bytes_per_chunk = vad.samples_per_chunk * 2 + vad_buffer = AudioBuffer(bytes_per_chunk) + segmenter = VoiceCommandSegmenter() + + with patch.object(vad, "is_speech", return_value=False) as mock_process: + # Partially fill audio buffer + half_chunk = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk // 2)) + segmenter.process_with_vad(half_chunk, vad, vad_buffer) + + assert not mock_process.called + assert vad_buffer is not None + assert vad_buffer.bytes() == half_chunk + + # Fill and wrap with 1/4 chunk left over + three_quarters_chunk = bytes( + it.islice(it.cycle(range(256)), int(0.75 * bytes_per_chunk)) + ) + segmenter.process_with_vad(three_quarters_chunk, vad, vad_buffer) + + assert mock_process.call_count == 1 + assert ( + vad_buffer.bytes() + == three_quarters_chunk[ + len(three_quarters_chunk) - (bytes_per_chunk // 4) : + ] + ) + assert ( + mock_process.call_args[0][0] + == half_chunk + three_quarters_chunk[: bytes_per_chunk // 2] + ) + + # Run 2 chunks through + segmenter.reset() + vad_buffer.clear() + assert len(vad_buffer) == 0 + + mock_process.reset_mock() + two_chunks = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk * 2)) + segmenter.process_with_vad(two_chunks, vad, vad_buffer) + + assert mock_process.call_count == 2 + assert len(vad_buffer) == 0 + assert mock_process.call_args_list[0][0][0] == two_chunks[:bytes_per_chunk] + assert mock_process.call_args_list[1][0][0] == two_chunks[bytes_per_chunk:] def test_partial_chunk() -> None: @@ -125,3 +124,43 @@ def test_chunk_samples_leftover() -> None: assert len(chunks) == 1 assert leftover_chunk_buffer.bytes() == bytes([5, 6]) + + +def test_vad_no_chunking() -> None: + """Test VAD that doesn't require chunking.""" + + class VadNoChunk(VoiceActivityDetector): + def is_speech(self, chunk: bytes) -> bool: + return sum(chunk) > 0 + + @property + def samples_per_chunk(self) -> int | None: + return None + + vad = VadNoChunk() + segmenter = VoiceCommandSegmenter( + speech_seconds=1.0, silence_seconds=1.0, reset_seconds=0.5 + ) + silence = bytes([0] * 16000) + speech = bytes([255] * (16000 // 2)) + + # Test with differently-sized chunks + assert vad.is_speech(speech) + assert not vad.is_speech(silence) + + # Simulate voice command + assert segmenter.process_with_vad(silence, vad, None) + # begin + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + # reset with silence + assert segmenter.process_with_vad(silence, vad, None) + # resume + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + # end + assert segmenter.process_with_vad(silence, vad, None) + assert not segmenter.process_with_vad(silence, vad, None) diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index a7ba9063b3fb61..f995a0d35776b4 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -62,8 +62,8 @@ async def test_text_only_pipeline( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -107,6 +107,7 @@ async def test_audio_pipeline( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # stt @@ -116,7 +117,7 @@ async def test_audio_pipeline( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([1])) + await client.send_bytes(bytes([handler_id])) msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" @@ -152,8 +153,8 @@ async def test_audio_pipeline( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -240,6 +241,8 @@ async def test_audio_pipeline_with_wake_word_no_timeout( "input": { "sample_rate": 16000, "timeout": 0, + "no_vad": True, + "no_chunking": True, }, } ) @@ -253,6 +256,7 @@ async def test_audio_pipeline_with_wake_word_no_timeout( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # wake_word @@ -276,7 +280,7 @@ async def test_audio_pipeline_with_wake_word_no_timeout( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([1])) + await client.send_bytes(bytes([handler_id])) msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" @@ -312,8 +316,8 @@ async def test_audio_pipeline_with_wake_word_no_timeout( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -337,7 +341,7 @@ async def test_audio_pipeline_no_wake_word_engine( client = await hass_ws_client(hass) with patch( - "homeassistant.components.wake_word.async_default_engine", return_value=None + "homeassistant.components.wake_word.async_default_entity", return_value=None ): await client.send_json_auto_id( { @@ -367,7 +371,7 @@ async def test_audio_pipeline_no_wake_word_entity( client = await hass_ws_client(hass) with patch( - "homeassistant.components.wake_word.async_default_engine", + "homeassistant.components.wake_word.async_default_entity", return_value="wake_word.bad-entity-id", ), patch( "homeassistant.components.wake_word.async_get_wake_word_detection_entity", @@ -448,8 +452,8 @@ async def sleepy_converse(*args, **kwargs): events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -501,8 +505,8 @@ async def sleepy_run(*args, **kwargs): events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -569,8 +573,8 @@ async def test_intent_failed( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -624,8 +628,8 @@ async def sleepy_run(*args, **kwargs): events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -731,6 +735,7 @@ async def test_stt_stream_failed( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # stt @@ -740,7 +745,7 @@ async def test_stt_stream_failed( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(b"1") + await client.send_bytes(bytes([handler_id])) # stt error msg = await client.receive_json() @@ -755,8 +760,8 @@ async def test_stt_stream_failed( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -823,8 +828,8 @@ async def test_tts_failed( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -936,6 +941,8 @@ async def test_add_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -951,6 +958,8 @@ async def test_add_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } assert len(pipeline_store.data) == 2 @@ -966,6 +975,8 @@ async def test_add_pipeline( tts_engine="test_tts_engine", tts_language="test_language", tts_voice="Arnold Schwarzenegger", + wake_word_entity="wakeword_entity_1", + wake_word_id="wakeword_id_1", ) await client.send_json_auto_id( @@ -1000,6 +1011,8 @@ async def test_add_pipeline_missing_language( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1018,6 +1031,8 @@ async def test_add_pipeline_missing_language( "tts_engine": "test_tts_engine", "tts_language": None, "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1045,6 +1060,8 @@ async def test_delete_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1063,6 +1080,8 @@ async def test_delete_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_2", + "wake_word_id": "wakeword_id_2", } ) msg = await client.receive_json() @@ -1143,6 +1162,8 @@ async def test_get_pipeline( "tts_engine": "test", "tts_language": "en-US", "tts_voice": "james_earl_jones", + "wake_word_entity": None, + "wake_word_id": None, } await client.send_json_auto_id( @@ -1170,6 +1191,8 @@ async def test_get_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1196,6 +1219,8 @@ async def test_get_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } @@ -1221,6 +1246,8 @@ async def test_list_pipelines( "tts_engine": "test", "tts_language": "en-US", "tts_voice": "james_earl_jones", + "wake_word_entity": None, + "wake_word_id": None, } ], "preferred_pipeline": ANY, @@ -1248,6 +1275,8 @@ async def test_update_pipeline( "tts_engine": "new_tts_engine", "tts_language": "new_tts_language", "tts_voice": "new_tts_voice", + "wake_word_entity": "new_wakeword_entity", + "wake_word_id": "new_wakeword_id", } ) msg = await client.receive_json() @@ -1269,6 +1298,8 @@ async def test_update_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1289,6 +1320,8 @@ async def test_update_pipeline( "tts_engine": "new_tts_engine", "tts_language": "new_tts_language", "tts_voice": "new_tts_voice", + "wake_word_entity": "new_wakeword_entity", + "wake_word_id": "new_wakeword_id", } ) msg = await client.receive_json() @@ -1304,6 +1337,8 @@ async def test_update_pipeline( "tts_engine": "new_tts_engine", "tts_language": "new_tts_language", "tts_voice": "new_tts_voice", + "wake_word_entity": "new_wakeword_entity", + "wake_word_id": "new_wakeword_id", } assert len(pipeline_store.data) == 2 @@ -1319,6 +1354,8 @@ async def test_update_pipeline( tts_engine="new_tts_engine", tts_language="new_tts_language", tts_voice="new_tts_voice", + wake_word_entity="new_wakeword_entity", + wake_word_id="new_wakeword_id", ) await client.send_json_auto_id( @@ -1334,6 +1371,8 @@ async def test_update_pipeline( "tts_engine": None, "tts_language": None, "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() @@ -1349,6 +1388,8 @@ async def test_update_pipeline( "tts_engine": None, "tts_language": None, "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, } pipeline = pipeline_store.data[pipeline_id] @@ -1363,6 +1404,8 @@ async def test_update_pipeline( tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -1386,6 +1429,8 @@ async def test_set_preferred_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1449,6 +1494,7 @@ async def test_audio_pipeline_debug( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # stt @@ -1458,7 +1504,7 @@ async def test_audio_pipeline_debug( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([1])) + await client.send_bytes(bytes([handler_id])) msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" @@ -1659,3 +1705,103 @@ async def test_list_pipeline_languages_with_aliases( msg = await client.receive_json() assert msg["success"] assert msg["result"] == {"languages": ["he", "nb"]} + + +async def test_audio_pipeline_with_enhancements( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with audio input/output.""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + # Enhancements + "noise_suppression_level": 2, + "auto_gain_dbfs": 15, + "volume_multiplier": 2.0, + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + events.append(msg["event"]) + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # One second of silence. + # This will pass through the audio enhancement pipeline, but we don't test + # the actual output. + await client.send_bytes(bytes([handler_id]) + bytes(16000 * 2)) + + # End of audio stream (handler id + empty payload) + await client.send_bytes(bytes([handler_id])) + + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} diff --git a/tests/components/aurora/test_config_flow.py b/tests/components/aurora/test_config_flow.py index 401ee37382e54f..ebd7780900abd1 100644 --- a/tests/components/aurora/test_config_flow.py +++ b/tests/components/aurora/test_config_flow.py @@ -10,7 +10,6 @@ from tests.common import MockConfigEntry DATA = { - "name": "Home", "latitude": -10, "longitude": 10.2, } @@ -39,7 +38,7 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "Aurora - Home" + assert result2["title"] == "Aurora visibility" assert result2["data"] == DATA assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 5662bc6324b0da..fc870f2bfe3816 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -23,6 +23,8 @@ from homeassistant.components.bluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) from homeassistant.core import HomeAssistant, callback @@ -557,3 +559,82 @@ def _bparasite_device_unavailable_callback(_address: str) -> None: cancel() unsetup() + + +async def test_scanner_stops_responding( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_bluetooth: None +) -> None: + """Test we mark a scanner are not scanning when it stops responding.""" + manager = _get_manager() + + class FakeScanner(BaseHaRemoteScanner): + """A fake remote scanner.""" + + def inject_advertisement( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), + ) + + new_info_callback = manager.scanner_adv_received + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + unsetup = scanner.async_setup() + cancel = manager.async_register_scanner(scanner, True) + + start_time_monotonic = time.monotonic() + + assert scanner.scanning is True + failure_reached_time = ( + start_time_monotonic + + SCANNER_WATCHDOG_TIMEOUT + + SCANNER_WATCHDOG_INTERVAL.total_seconds() + ) + # We hit the timer with no detections, so we reset the adapter and restart the scanner + with patch( + "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + return_value=failure_reached_time, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() + + assert scanner.scanning is False + + bparasite_device = generate_ble_device( + "44:44:33:11:23:45", + "bparasite", + {}, + rssi=-100, + ) + bparasite_device_adv = generate_advertisement_data( + local_name="bparasite", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + + failure_reached_time += 1 + + with patch( + "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + return_value=failure_reached_time, + ): + scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + + # As soon as we get a detection, we know the scanner is working again + assert scanner.scanning is True + + cancel() + unsetup() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 63091b18843242..6c89074e471917 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1011,6 +1011,7 @@ async def test_debug_logging( caplog: pytest.LogCaptureFixture, ) -> None: """Test debug logging.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) await hass.services.async_call( "logger", "set_level", diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index ddd2049800a267..879293ae9590ca 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -1,5 +1,6 @@ """Test the CO2 Signal config flow.""" -from unittest.mock import patch +from json import JSONDecodeError +from unittest.mock import Mock, patch import pytest @@ -131,14 +132,33 @@ async def test_form_country(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("err_str", "err_code"), + ("side_effect", "err_code"), [ - ("Invalid authentication credentials", "invalid_auth"), - ("API rate limit exceeded.", "api_ratelimit"), - ("Something else", "unknown"), + ( + ValueError("Invalid authentication credentials"), + "invalid_auth", + ), + ( + ValueError("API rate limit exceeded."), + "api_ratelimit", + ), + (ValueError("Something else"), "unknown"), + (JSONDecodeError(msg="boom", doc="", pos=1), "unknown"), + (Exception("Boom"), "unknown"), + (Mock(return_value={"error": "boom"}), "unknown"), + (Mock(return_value={"status": "error"}), "unknown"), + ], + ids=[ + "invalid auth", + "rate limit exceeded", + "unknown value error", + "json decode error", + "unknown error", + "error in json dict", + "status error", ], ) -async def test_form_error_handling(hass: HomeAssistant, err_str, err_code) -> None: +async def test_form_error_handling(hass: HomeAssistant, side_effect, err_code) -> None: """Test we handle expected errors.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -146,9 +166,9 @@ async def test_form_error_handling(hass: HomeAssistant, err_str, err_code) -> No with patch( "CO2Signal.get_latest", - side_effect=ValueError(err_str), + side_effect=side_effect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "location": config_flow.TYPE_USE_HOME, @@ -156,49 +176,24 @@ async def test_form_error_handling(hass: HomeAssistant, err_str, err_code) -> No }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": err_code} - - -async def test_form_error_unexpected_error(hass: HomeAssistant) -> None: - """Test we handle unexpected error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "CO2Signal.get_latest", - side_effect=Exception("Boom"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "location": config_flow.TYPE_USE_HOME, - "api_key": "api_key", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_form_error_unexpected_data(hass: HomeAssistant) -> None: - """Test we handle unexpected data.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": err_code} with patch( "CO2Signal.get_latest", - return_value={"status": "error"}, + return_value=VALID_PAYLOAD, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "location": config_flow.TYPE_USE_HOME, "api_key": "api_key", }, ) + await hass.async_block_till_done() - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "CO2 Signal" + assert result["data"] == { + "api_key": "api_key", + } diff --git a/tests/components/color_extractor/test_config_flow.py b/tests/components/color_extractor/test_config_flow.py new file mode 100644 index 00000000000000..9dc928da73f76a --- /dev/null +++ b/tests/components/color_extractor/test_config_flow.py @@ -0,0 +1,70 @@ +"""Tests for the Color extractor config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.color_extractor.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + + with patch( + "homeassistant.components.color_extractor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("title") == "Color extractor" + assert result.get("data") == {} + assert result.get("options") == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) +async def test_single_instance_allowed( + hass: HomeAssistant, + source: str, +) -> None: + """Test we abort if already setup.""" + mock_config_entry = MockConfigEntry(domain=DOMAIN) + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data={} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" + + +async def test_import_flow( + hass: HomeAssistant, +) -> None: + """Test the import configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={}, + ) + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("title") == "Color extractor" + assert result.get("data") == {} + assert result.get("options") == {} diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index 312183878586f1..ae3e799e9d290f 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -63,7 +63,7 @@ def _close_enough(actual_rgb, testing_rgb): @pytest.fixture(autouse=True) -async def setup_light(hass): +async def setup_light(hass: HomeAssistant): """Configure our light component to work against for testing.""" assert await async_setup_component( hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": "demo"}} diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index b7ce56704410e3..9b6bcf1c6c7a0b 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -10,10 +10,12 @@ from homeassistant.components.assist_pipeline import ( PipelineEvent, PipelineEventType, - PipelineNotFound, PipelineStage, ) -from homeassistant.components.assist_pipeline.error import WakeWordDetectionError +from homeassistant.components.assist_pipeline.error import ( + WakeWordDetectionAborted, + WakeWordDetectionError, +) from homeassistant.components.esphome import DomainData from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer from homeassistant.core import HomeAssistant @@ -370,6 +372,8 @@ async def async_pipeline_from_audio_stream(*args, start_stage, **kwargs): with patch( "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, + ), patch( + "asyncio.Event.wait" # TTS wait event ): voice_assistant_udp_server_v2.transport = Mock() @@ -377,7 +381,6 @@ async def async_pipeline_from_audio_stream(*args, start_stage, **kwargs): device_id="mock-device-id", conversation_id=None, flags=2, - pipeline_timeout=1, ) @@ -410,38 +413,28 @@ def handle_event( device_id="mock-device-id", conversation_id=None, flags=2, - pipeline_timeout=1, ) -async def test_pipeline_timeout( +async def test_wake_word_abort_exception( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: """Test that the pipeline is set to start with Wake word.""" async def async_pipeline_from_audio_stream(*args, **kwargs): - raise PipelineNotFound("not-found", "Pipeline not found") + raise WakeWordDetectionAborted with patch( "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, - ): + ), patch.object(voice_assistant_udp_server_v2, "handle_event") as mock_handle_event: voice_assistant_udp_server_v2.transport = Mock() - def handle_event( - event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: - assert data is not None - assert data["code"] == "pipeline not found" - assert data["message"] == "Selected pipeline not found" - - voice_assistant_udp_server_v2.handle_event = handle_event - await voice_assistant_udp_server_v2.run_pipeline( device_id="mock-device-id", conversation_id=None, flags=2, - pipeline_timeout=1, ) + + mock_handle_event.assert_not_called() diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 291951a745af89..7499a0609330f4 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -66,14 +66,23 @@ def mock_monitored_resources() -> list[str] | None: return None +@pytest.fixture(name="configured_unit_system") +def mock_configured_unit_syststem() -> str | None: + """Fixture for the fitbit yaml config monitored_resources field.""" + return None + + @pytest.fixture(name="sensor_platform_config") def mock_sensor_platform_config( monitored_resources: list[str] | None, + configured_unit_system: str | None, ) -> dict[str, Any]: """Fixture for the fitbit sensor platform configuration data in configuration.yaml.""" config = {} if monitored_resources is not None: config["monitored_resources"] = monitored_resources + if configured_unit_system is not None: + config["unit_system"] = configured_unit_system return config diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 7351f919380cc5..636afeacf168ee 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -244,11 +244,27 @@ async def test_device_battery_level( @pytest.mark.parametrize( - ("monitored_resources", "profile_locale", "expected_unit"), + ( + "monitored_resources", + "profile_locale", + "configured_unit_system", + "expected_unit", + ), [ - (["body/weight"], "en_US", "kg"), - (["body/weight"], "en_GB", "st"), - (["body/weight"], "es_ES", "kg"), + # Defaults to home assistant unit system unless UK + (["body/weight"], "en_US", "default", "kg"), + (["body/weight"], "en_GB", "default", "st"), + (["body/weight"], "es_ES", "default", "kg"), + # Use the configured unit system from yaml + (["body/weight"], "en_US", "en_US", "lb"), + (["body/weight"], "en_GB", "en_US", "lb"), + (["body/weight"], "es_ES", "en_US", "lb"), + (["body/weight"], "en_US", "en_GB", "st"), + (["body/weight"], "en_GB", "en_GB", "st"), + (["body/weight"], "es_ES", "en_GB", "st"), + (["body/weight"], "en_US", "metric", "kg"), + (["body/weight"], "en_GB", "metric", "kg"), + (["body/weight"], "es_ES", "metric", "kg"), ], ) async def test_profile_local( diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 69b250412bd0da..63bc1d76d1a191 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -1,5 +1,5 @@ """Test helpers for Freebox.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch import pytest @@ -10,6 +10,7 @@ DATA_CALL_GET_CALLS_LOG, DATA_CONNECTION_GET_STATUS, DATA_HOME_GET_NODES, + DATA_HOME_GET_VALUES, DATA_LAN_GET_HOSTS_LIST, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, @@ -27,6 +28,16 @@ def mock_path(): yield +@pytest.fixture(autouse=True) +def enable_all_entities(): + """Make sure all entities are enabled.""" + with patch( + "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", + PropertyMock(return_value=True), + ): + yield + + @pytest.fixture def mock_device_registry_devices(hass: HomeAssistant, device_registry): """Create device registry devices so the device tracker entities are enabled.""" @@ -56,18 +67,21 @@ def mock_router(mock_device_registry_devices): instance = service_mock.return_value instance.open = AsyncMock() instance.system.get_config = AsyncMock(return_value=DATA_SYSTEM_GET_CONFIG) + # device_tracker + instance.lan.get_hosts_list = AsyncMock(return_value=DATA_LAN_GET_HOSTS_LIST) # sensor instance.call.get_calls_log = AsyncMock(return_value=DATA_CALL_GET_CALLS_LOG) instance.storage.get_disks = AsyncMock(return_value=DATA_STORAGE_GET_DISKS) instance.storage.get_raids = AsyncMock(return_value=DATA_STORAGE_GET_RAIDS) - # home devices - instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) instance.connection.get_status = AsyncMock( return_value=DATA_CONNECTION_GET_STATUS ) # switch instance.wifi.get_global_config = AsyncMock(return_value=WIFI_GET_GLOBAL_CONFIG) - # device_tracker - instance.lan.get_hosts_list = AsyncMock(return_value=DATA_LAN_GET_HOSTS_LIST) + # home devices + instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) + instance.home.get_home_endpoint_value = AsyncMock( + return_value=DATA_HOME_GET_VALUES + ) instance.close = AsyncMock() yield service_mock diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 0b58348a5dfb86..788310bdbc0ac2 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -513,7 +513,22 @@ }, ] +# Home +# PIR node id 26, endpoint id 6 +DATA_HOME_GET_VALUES = { + "category": "", + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", +} +# Home +# ALL DATA_HOME_GET_NODES = [ { "adapter": 2, @@ -2110,6 +2125,22 @@ "value_type": "bool", "visibility": "normal", }, + { + "category": "", + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "refresh": 2000, + "ui": { + "access": "r", + "display": "warning", + "icon_url": "/resources/images/home/pictos/warning.png", + }, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, { "category": "", "ep_type": "signal", @@ -2211,7 +2242,7 @@ "ep_type": "signal", "id": 7, "label": "Couvercle", - "name": "1cover", + "name": "cover", "param_type": "void", "value_type": "bool", "visibility": "normal", @@ -2302,6 +2333,33 @@ "value_type": "bool", "visibility": "normal", }, + { + "category": "", + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "refresh": 2000, + "ui": { + "access": "r", + "display": "warning", + "icon_url": "/resources/images/home/pictos/warning.png", + }, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, { "category": "", "ep_type": "signal", diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index 218ef953ee0523..b37d6a3c72c1b4 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -4,12 +4,16 @@ from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_STORAGE_GET_RAIDS +from .const import DATA_HOME_GET_VALUES, DATA_STORAGE_GET_RAIDS from tests.common import async_fire_time_changed @@ -38,3 +42,47 @@ async def test_raid_array_degraded( hass.states.get("binary_sensor.freebox_server_r2_raid_array_0_degraded").state == "on" ) + + +async def test_home( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test home binary sensors.""" + await setup_platform(hass, BINARY_SENSOR_DOMAIN) + + # Device class + assert ( + hass.states.get("binary_sensor.detecteur").attributes[ATTR_DEVICE_CLASS] + == BinarySensorDeviceClass.MOTION + ) + assert ( + hass.states.get("binary_sensor.ouverture_porte").attributes[ATTR_DEVICE_CLASS] + == BinarySensorDeviceClass.DOOR + ) + assert ( + hass.states.get("binary_sensor.ouverture_porte_couvercle").attributes[ + ATTR_DEVICE_CLASS + ] + == BinarySensorDeviceClass.SAFETY + ) + + # Initial state + assert hass.states.get("binary_sensor.detecteur").state == "on" + assert hass.states.get("binary_sensor.detecteur_couvercle").state == "off" + assert hass.states.get("binary_sensor.ouverture_porte").state == "unknown" + assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "off" + + # Now simulate a changed status + data_home_get_values_changed = deepcopy(DATA_HOME_GET_VALUES) + data_home_get_values_changed["value"] = True + router().home.get_home_endpoint_value.return_value = data_home_get_values_changed + + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.detecteur").state == "off" + assert hass.states.get("binary_sensor.detecteur_couvercle").state == "on" + assert hass.states.get("binary_sensor.ouverture_porte").state == "off" + assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "on" diff --git a/tests/components/freebox/test_sensor.py b/tests/components/freebox/test_sensor.py index 801e8508d86d1a..0abdc55b92ce4a 100644 --- a/tests/components/freebox/test_sensor.py +++ b/tests/components/freebox/test_sensor.py @@ -104,8 +104,8 @@ async def test_battery( # Simulate a changed battery data_home_get_nodes_changed = deepcopy(DATA_HOME_GET_NODES) data_home_get_nodes_changed[2]["show_endpoints"][3]["value"] = 25 - data_home_get_nodes_changed[3]["show_endpoints"][3]["value"] = 50 - data_home_get_nodes_changed[4]["show_endpoints"][3]["value"] = 75 + data_home_get_nodes_changed[3]["show_endpoints"][4]["value"] = 50 + data_home_get_nodes_changed[4]["show_endpoints"][5]["value"] = 75 router().home.get_home_nodes.return_value = data_home_get_nodes_changed # Simulate an update freezer.tick(SCAN_INTERVAL) diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index dd5a8127185b9f..b07b8225c3e037 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -21,12 +21,14 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from . import FritzDeviceSwitchMock, setup_config_entry from .const import CONF_FAKE_AIN, CONF_FAKE_NAME, MOCK_CONFIG from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: @@ -250,6 +252,68 @@ async def test_unload_remove(hass: HomeAssistant, fritz: Mock) -> None: assert state is None +async def test_remove_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + fritz: Mock, +) -> None: + """Test removing of a device.""" + assert await async_setup_component(hass, "config", {}) + assert await setup_config_entry( + hass, + MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + f"{FB_DOMAIN}.{CONF_FAKE_NAME}", + FritzDeviceSwitchMock(), + fritz, + ) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries() + assert len(entries) == 1 + + entry = entries[0] + assert entry.supports_remove_device + + entity = entity_registry.async_get("switch.fake_name") + good_device = device_registry.async_get(entity.device_id) + + orphan_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(FB_DOMAIN, "0000 000000")}, + ) + + # try to delete good_device + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": entry.entry_id, + "device_id": good_device.id, + } + ) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "unknown_error" + await hass.async_block_till_done() + + # try to delete orphan_device + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": entry.entry_id, + "device_id": orphan_device.id, + } + ) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> None: """Config entry state is SETUP_RETRY when fritzbox is offline.""" entry = MockConfigEntry( diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index d4d25d8b86f6ff..87ec80da057b45 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -1,7 +1,10 @@ """Tests for Glances config flow.""" from unittest.mock import MagicMock -from glances_api.exceptions import GlancesApiConnectionError +from glances_api.exceptions import ( + GlancesApiAuthorizationError, + GlancesApiConnectionError, +) import pytest from homeassistant import config_entries @@ -9,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import MOCK_USER_INPUT +from . import HA_SENSOR_DATA, MOCK_USER_INPUT from tests.common import MockConfigEntry, patch @@ -39,10 +42,19 @@ async def test_form(hass: HomeAssistant) -> None: assert result["data"] == MOCK_USER_INPUT -async def test_form_cannot_connect(hass: HomeAssistant, mock_api: MagicMock) -> None: - """Test to return error if we cannot connect.""" +@pytest.mark.parametrize( + ("error", "message"), + [ + (GlancesApiAuthorizationError, "invalid_auth"), + (GlancesApiConnectionError, "cannot_connect"), + ], +) +async def test_form_fails( + hass: HomeAssistant, error: Exception, message: str, mock_api: MagicMock +) -> None: + """Test flow fails when api exception is raised.""" - mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = [error, HA_SENSOR_DATA] result = await hass.config_entries.flow.async_init( glances.DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -51,7 +63,13 @@ async def test_form_cannot_connect(hass: HomeAssistant, mock_api: MagicMock) -> ) assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": message} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY async def test_form_already_configured(hass: HomeAssistant) -> None: @@ -67,3 +85,81 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_success(hass: HomeAssistant) -> None: + """Test we can reauth.""" + entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + glances.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {"username": "username"} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "password": "new-password", + }, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (GlancesApiAuthorizationError, "invalid_auth"), + (GlancesApiConnectionError, "cannot_connect"), + ], +) +async def test_reauth_fails( + hass: HomeAssistant, error: Exception, message: str, mock_api: MagicMock +) -> None: + """Test we can reauth.""" + entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + mock_api.return_value.get_ha_sensor_data.side_effect = [error, HA_SENSOR_DATA] + result = await hass.config_entries.flow.async_init( + glances.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {"username": "username"} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "password": "new-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": message} + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "password": "new-password", + }, + ) + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 546f57ac3d9258..61cbc610060950 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -1,7 +1,11 @@ """Tests for Glances integration.""" from unittest.mock import MagicMock -from glances_api.exceptions import GlancesApiConnectionError +from glances_api.exceptions import ( + GlancesApiAuthorizationError, + GlancesApiConnectionError, +) +import pytest from homeassistant.components.glances.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -23,15 +27,27 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: assert entry.state == ConfigEntryState.LOADED -async def test_conn_error(hass: HomeAssistant, mock_api: MagicMock) -> None: - """Test Glances failed due to connection error.""" +@pytest.mark.parametrize( + ("error", "entry_state"), + [ + (GlancesApiAuthorizationError, ConfigEntryState.SETUP_ERROR), + (GlancesApiConnectionError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_error( + hass: HomeAssistant, + error: Exception, + entry_state: ConfigEntryState, + mock_api: MagicMock, +) -> None: + """Test Glances failed due to api error.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) entry.add_to_hass(hass) - mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = error await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + assert entry.state is entry_state async def test_unload_entry(hass: HomeAssistant) -> None: diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index 8d425ae06487ca..dffcddf5de51a8 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -87,6 +87,7 @@ 'binary_sensor', 'climate', 'cover', + 'event', 'fan', 'group', 'humidifier', diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 001e8ff0d075e5..57915968933c36 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -306,7 +306,7 @@ async def test_agent_user_id_connect() -> None: @pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}]) async def test_report_state_all(agents) -> None: - """Test a disconnect message.""" + """Test sync of all states.""" config = MockConfig(agent_user_ids=agents) data = {} with patch.object(config, "async_report_state") as mock: @@ -314,6 +314,28 @@ async def test_report_state_all(agents) -> None: assert sorted(mock.mock_calls) == sorted(call(data, agent) for agent in agents) +@pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}]) +async def test_sync_entities(agents) -> None: + """Test sync of all entities.""" + config = MockConfig(agent_user_ids=agents) + with patch.object( + config, "async_sync_entities", return_value=HTTPStatus.NO_CONTENT + ) as mock: + await config.async_sync_entities_all() + assert sorted(mock.mock_calls) == sorted(call(agent) for agent in agents) + + +@pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}]) +async def test_sync_notifications(agents) -> None: + """Test sync of notifications.""" + config = MockConfig(agent_user_ids=agents) + with patch.object( + config, "async_sync_notification", return_value=HTTPStatus.NO_CONTENT + ) as mock: + await config.async_sync_notification_all("1234", {}) + assert not agents or bool(mock.mock_calls) and agents + + @pytest.mark.parametrize( ("agents", "result"), [({}, 204), ({"1": 200}, 200), ({"1": 200, "2": 300}, 300)], diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 44dc40f5a4785c..62d2722c445399 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -3,6 +3,7 @@ from http import HTTPStatus from typing import Any from unittest.mock import ANY, patch +from uuid import uuid4 import pytest @@ -195,6 +196,38 @@ async def test_report_state( ) +async def test_report_event( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_storage: dict[str, Any], +) -> None: + """Test the report event function.""" + agent_user_id = "user" + config = GoogleConfig(hass, DUMMY_CONFIG) + await config.async_initialize() + + await config.async_connect_agent_user(agent_user_id) + message = {"devices": {}} + + with patch.object(config, "async_call_homegraph_api"): + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await hass.async_block_till_done() + + event_id = uuid4().hex + with patch.object(config, "async_call_homegraph_api") as mock_call: + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await config.async_report_state(message, agent_user_id, event_id=event_id) + mock_call.assert_called_once_with( + REPORT_STATE_BASE_URL, + { + "requestId": ANY, + "agentUserId": agent_user_id, + "payload": message, + "eventId": event_id, + }, + ) + + async def test_google_config_local_fulfillment( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/google_assistant/test_report_state.py b/tests/components/google_assistant/test_report_state.py index d6f4043d2f7d22..4ec61b75171b03 100644 --- a/tests/components/google_assistant/test_report_state.py +++ b/tests/components/google_assistant/test_report_state.py @@ -1,5 +1,7 @@ """Test Google report state.""" -from datetime import timedelta +from datetime import datetime, timedelta +from http import HTTPStatus +from time import mktime from unittest.mock import AsyncMock, patch import pytest @@ -9,7 +11,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from . import BASIC_CONFIG +from . import BASIC_CONFIG, MockConfig from tests.common import async_fire_time_changed @@ -21,6 +23,9 @@ async def test_report_state( assert await async_setup_component(hass, "switch", {}) hass.states.async_set("light.ceiling", "off") hass.states.async_set("switch.ac", "on") + hass.states.async_set( + "event.doorbell", "unknown", attributes={"device_class": "doorbell"} + ) with patch.object( BASIC_CONFIG, "async_report_state_all", AsyncMock() @@ -37,6 +42,7 @@ async def test_report_state( "states": { "light.ceiling": {"on": False, "online": True}, "switch.ac": {"on": True, "online": True}, + "event.doorbell": {"online": True}, } } } @@ -128,3 +134,145 @@ async def test_report_state( await hass.async_block_till_done() assert len(mock_report.mock_calls) == 0 + + +@pytest.mark.freeze_time("2023-08-01 00:00:00") +async def test_report_notifications( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test report state works.""" + config = MockConfig(agent_user_ids={"1"}) + + assert await async_setup_component(hass, "event", {}) + hass.states.async_set( + "event.doorbell", "unknown", attributes={"device_class": "doorbell"} + ) + + with patch.object( + config, "async_report_state_all", AsyncMock() + ) as mock_report, patch.object(report_state, "INITIAL_REPORT_DELAY", 0): + report_state.async_enable_report_state(hass, config) + + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T00:01:00+00:00") + ) + await hass.async_block_till_done() + + # Test that enabling report state does a report on event entities + assert len(mock_report.mock_calls) == 1 + assert mock_report.mock_calls[0][1][0] == { + "devices": { + "states": { + "event.doorbell": {"online": True}, + }, + } + } + + with patch.object( + config, "async_report_state", return_value=HTTPStatus(200) + ) as mock_report_state: + event_time = datetime.fromisoformat("2023-08-01T00:02:57+00:00") + epoc_event_time = int(mktime(event_time.timetuple())) + hass.states.async_set( + "event.doorbell", + "2023-08-01T00:02:57+00:00", + attributes={"device_class": "doorbell"}, + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T00:03:00+00:00") + ) + await hass.async_block_till_done() + + assert len(mock_report_state.mock_calls) == 1 + notifications_payload = mock_report_state.mock_calls[0][1][0]["devices"][ + "notifications" + ]["event.doorbell"] + assert notifications_payload == { + "ObjectDetection": { + "objects": {"unclassified": 1}, + "priority": 0, + "detectionTimestamp": epoc_event_time * 1000, + } + } + assert "Sending event notification for entity event.doorbell" in caplog.text + assert "Unable to send notification with result code" not in caplog.text + + hass.states.async_set( + "event.doorbell", "unknown", attributes={"device_class": "doorbell"} + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T01:01:00+00:00") + ) + await hass.async_block_till_done() + + # Test the notification request failed + caplog.clear() + with patch.object( + config, "async_report_state", return_value=HTTPStatus(500) + ) as mock_report_state: + event_time = datetime.fromisoformat("2023-08-01T01:02:57+00:00") + epoc_event_time = int(mktime(event_time.timetuple())) + hass.states.async_set( + "event.doorbell", + "2023-08-01T01:02:57+00:00", + attributes={"device_class": "doorbell"}, + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T01:03:00+00:00") + ) + await hass.async_block_till_done() + assert len(mock_report_state.mock_calls) == 2 + for call in mock_report_state.mock_calls: + if "notifications" in call[1][0]["devices"]: + notifications = call[1][0]["devices"]["notifications"] + elif "states" in call[1][0]["devices"]: + states = call[1][0]["devices"]["states"] + assert notifications["event.doorbell"] == { + "ObjectDetection": { + "objects": {"unclassified": 1}, + "priority": 0, + "detectionTimestamp": epoc_event_time * 1000, + } + } + assert states["event.doorbell"] == {"online": True} + assert "Sending event notification for entity event.doorbell" in caplog.text + assert ( + "Unable to send notification with result code: 500, check log for more info" + in caplog.text + ) + + # Test disconnecting agent user + caplog.clear() + with patch.object( + config, "async_report_state", return_value=HTTPStatus.NOT_FOUND + ) as mock_report_state, patch.object(config, "async_disconnect_agent_user"): + event_time = datetime.fromisoformat("2023-08-01T01:03:57+00:00") + epoc_event_time = int(mktime(event_time.timetuple())) + hass.states.async_set( + "event.doorbell", + "2023-08-01T01:03:57+00:00", + attributes={"device_class": "doorbell"}, + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T01:04:00+00:00") + ) + await hass.async_block_till_done() + assert len(mock_report_state.mock_calls) == 2 + for call in mock_report_state.mock_calls: + if "notifications" in call[1][0]["devices"]: + notifications = call[1][0]["devices"]["notifications"] + elif "states" in call[1][0]["devices"]: + states = call[1][0]["devices"]["states"] + assert notifications["event.doorbell"] == { + "ObjectDetection": { + "objects": {"unclassified": 1}, + "priority": 0, + "detectionTimestamp": epoc_event_time * 1000, + } + } + assert states["event.doorbell"] == {"online": True} + assert "Sending event notification for entity event.doorbell" in caplog.text + assert ( + "Unable to send notification with result code: 404, check log for more info" + in caplog.text + ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index fcbf16c21c71bd..db4257bb621239 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -11,6 +11,7 @@ camera, climate, cover, + event, fan, group, humidifier, @@ -220,6 +221,42 @@ async def test_onoff_input_boolean(hass: HomeAssistant) -> None: assert off_calls[0].data == {ATTR_ENTITY_ID: "input_boolean.bla"} +@pytest.mark.freeze_time("2023-08-01T00:02:57+00:00") +async def test_doorbell_event(hass: HomeAssistant) -> None: + """Test doorbell event trait support for input_boolean domain.""" + assert trait.ObjectDetection.supported(event.DOMAIN, 0, "doorbell", None) + + state = State( + "event.bla", + "2023-08-01T00:02:57+00:00", + attributes={"device_class": "doorbell"}, + ) + trt_od = trait.ObjectDetection(hass, state, BASIC_CONFIG) + + assert not trt_od.sync_attributes() + assert trt_od.sync_options() == {"notificationSupportedByAgent": True} + assert not trt_od.query_attributes() + time_stamp = datetime.fromisoformat(state.state) + assert trt_od.query_notifications() == { + "ObjectDetection": { + "objects": { + "unclassified": 1, + }, + "priority": 0, + "detectionTimestamp": int(time_stamp.timestamp() * 1000), + } + } + + # Test that stale notifications (older than 30 s) are dropped + state = State( + "event.bla", + "2023-08-01T00:02:22+00:00", + attributes={"device_class": "doorbell"}, + ) + trt_od = trait.ObjectDetection(hass, state, BASIC_CONFIG) + assert trt_od.query_notifications() is None + + async def test_onoff_switch(hass: HomeAssistant) -> None: """Test OnOff trait support for switch domain.""" assert helpers.get_google_type(switch.DOMAIN, None) is not None diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 5a89ea8335a91e..06c726360d9008 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -320,8 +320,8 @@ async def test_api_ingress_panels( ], ) async def test_api_headers( + aiohttp_raw_server, # 'aiohttp_raw_server' must be before 'hass'! hass, - aiohttp_raw_server, socket_enabled, api_call: str, method: Literal["GET", "POST"], @@ -364,6 +364,48 @@ async def mock_handler(request): assert received_request.headers[hdrs.CONTENT_TYPE] == "application/octet-stream" +async def test_api_get_green_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.get( + "http://127.0.0.1/os/boards/green", + json={ + "result": "ok", + "data": { + "activity_led": True, + "power_led": True, + "system_health_led": True, + }, + }, + ) + + assert await handler.async_get_green_settings(hass) == { + "activity_led": True, + "power_led": True, + "system_health_led": True, + } + assert aioclient_mock.call_count == 1 + + +async def test_api_set_green_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.post( + "http://127.0.0.1/os/boards/green", + json={"result": "ok", "data": {}}, + ) + + assert ( + await handler.async_set_green_settings( + hass, {"activity_led": True, "power_led": True, "system_health_led": True} + ) + == {} + ) + assert aioclient_mock.call_count == 1 + + async def test_api_get_yellow_settings( hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 48f52ee7c2416e..adb462b02e37eb 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -548,7 +548,7 @@ async def test_service_calls( assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { - "name": "2021-11-13 11:48:00", + "name": "2021-11-13 03:48:00", "homeassistant": True, "addons": ["test"], "folders": ["ssl"], @@ -605,6 +605,24 @@ async def test_service_calls( await hass.async_block_till_done() assert aioclient_mock.call_count == 34 + assert aioclient_mock.mock_calls[-1][2] == { + "name": "2021-11-13 03:48:00", + "location": None, + } + + # check backup with different timezone + await hass.config.async_update(time_zone="Europe/London") + + await hass.services.async_call( + "hassio", + "backup_full", + { + "location": "/backup", + }, + ) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 36 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, diff --git a/tests/components/homeassistant_green/test_config_flow.py b/tests/components/homeassistant_green/test_config_flow.py index 2eb7389af55e9e..84af22509f9d61 100644 --- a/tests/components/homeassistant_green/test_config_flow.py +++ b/tests/components/homeassistant_green/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Home Assistant Green config flow.""" from unittest.mock import patch +import pytest + from homeassistant.components.homeassistant_green.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -8,6 +10,29 @@ from tests.common import MockConfigEntry, MockModule, mock_integration +@pytest.fixture(name="get_green_settings") +def mock_get_green_settings(): + """Mock getting green settings.""" + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_get_green_settings", + return_value={ + "activity_led": True, + "power_led": True, + "system_health_led": True, + }, + ) as get_green_settings: + yield get_green_settings + + +@pytest.fixture(name="set_green_settings") +def mock_set_green_settings(): + """Mock setting green settings.""" + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_set_green_settings", + ) as set_green_settings: + yield set_green_settings + + async def test_config_flow(hass: HomeAssistant) -> None: """Test the config flow.""" mock_integration(hass, MockModule("hassio")) @@ -56,3 +81,142 @@ async def test_config_flow_single_entry(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" mock_setup_entry.assert_not_called() + + +async def test_option_flow_non_hassio( + hass: HomeAssistant, +) -> None: + """Test installing the multi pan addon on a Core installation, without hassio.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_green.config_flow.is_hassio", + return_value=False, + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_hassio" + + +async def test_option_flow_led_settings( + hass: HomeAssistant, + get_green_settings, + set_green_settings, +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": False, "power_led": False, "system_health_led": False}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + set_green_settings.assert_called_once_with( + hass, {"activity_led": False, "power_led": False, "system_health_led": False} + ) + + +async def test_option_flow_led_settings_unchanged( + hass: HomeAssistant, + get_green_settings, + set_green_settings, +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": True, "power_led": True, "system_health_led": True}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + set_green_settings.assert_not_called() + + +async def test_option_flow_led_settings_fail_1(hass: HomeAssistant) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_get_green_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "read_hw_settings_error" + + +async def test_option_flow_led_settings_fail_2( + hass: HomeAssistant, get_green_settings +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_set_green_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": False, "power_led": False, "system_health_led": False}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "write_hw_settings_error" diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index b57dd2da10f7a6..960647a22e60a8 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -17,7 +17,10 @@ TYPE_SWITCH, TYPE_VALVE, ) -from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntityFeature, +) from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.const import ( @@ -202,7 +205,14 @@ def test_type_covers(type_name, entity_id, state, attrs) -> None: "TelevisionMediaPlayer", "media_player.tv", "on", - {ATTR_DEVICE_CLASS: "tv"}, + {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV}, + {}, + ), + ( + "ReceiverMediaPlayer", + "media_player.receiver", + "on", + {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.RECEIVER}, {}, ), ], diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 02807ba65573fa..00281b491c4006 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -765,6 +765,7 @@ async def test_homekit_start( assert device formatted_mac = dr.format_mac(homekit.driver.state.mac) assert (dr.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + assert device.model == "HomeBridge" assert len(device_registry.devices) == 1 assert homekit.driver.state.config_version == 1 @@ -2010,6 +2011,16 @@ async def test_homekit_start_in_accessory_mode( assert hk_driver_start.called assert homekit.status == STATUS_RUNNING + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} + ) + assert device + formatted_mac = dr.format_mac(homekit.driver.state.mac) + assert (dr.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + assert device.model == "Light" + + assert len(device_registry.devices) == 1 + async def test_homekit_start_in_accessory_mode_unsupported_entity( hass: HomeAssistant, diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 9da576b6a0e075..b88412896118af 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -74,17 +74,19 @@ async def test_garage_door_open_close(hass: HomeAssistant, hk_driver, events) -> assert acc.char_obstruction_detected.value is True hass.states.async_set( - entity_id, STATE_UNAVAILABLE, {ATTR_OBSTRUCTION_DETECTED: False} + entity_id, STATE_UNAVAILABLE, {ATTR_OBSTRUCTION_DETECTED: True} ) await hass.async_block_till_done() assert acc.char_current_state.value == HK_DOOR_OPEN assert acc.char_target_state.value == HK_DOOR_OPEN - assert acc.char_obstruction_detected.value is False + assert acc.char_obstruction_detected.value is True + assert acc.available is False hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() assert acc.char_current_state.value == HK_DOOR_OPEN assert acc.char_target_state.value == HK_DOOR_OPEN + assert acc.available is True # Set from HomeKit call_close_cover = async_mock_service(hass, DOMAIN, "close_cover") diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index 32f1561644e8a1..dc614ee54c468f 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -13,6 +13,7 @@ ATTR_CODE, ATTR_ENTITY_ID, STATE_LOCKED, + STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, ) @@ -68,10 +69,32 @@ async def test_lock_unlock(hass: HomeAssistant, hk_driver, events) -> None: assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 0 - hass.states.async_remove(entity_id) + # Unavailable should keep last state + # but set the accessory to not available + hass.states.async_set(entity_id, STATE_UNAVAILABLE) await hass.async_block_till_done() assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 0 + assert acc.available is False + + hass.states.async_set(entity_id, STATE_UNLOCKED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + assert acc.available is True + + # Unavailable should keep last state + # but set the accessory to not available + hass.states.async_set(entity_id, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + assert acc.available is False + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 # Set from HomeKit call_lock = async_mock_service(hass, DOMAIN, "lock") diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index f68adc2407769e..3842303ec848c7 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -1,6 +1,7 @@ """Test different accessory types: Media Players.""" import pytest +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, @@ -15,6 +16,7 @@ ) from homeassistant.components.homekit.type_media_players import ( MediaPlayer, + ReceiverMediaPlayer, TelevisionMediaPlayer, ) from homeassistant.components.media_player import ( @@ -629,3 +631,29 @@ async def test_media_player_television_unsafe_chars( assert events[-1].data[ATTR_VALUE] is None assert acc.char_input_source.value == 4 + + +async def test_media_player_receiver( + hass: HomeAssistant, hk_driver: HomeDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test if television accessory with unsafe characters.""" + entity_id = "media_player.receiver" + sources = ["MUSIC", "HDMI 3/ARC", "SCREEN MIRRORING", "HDMI 2/MHL", "HDMI", "MUSIC"] + hass.states.async_set( + entity_id, + None, + { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE: "HDMI 2/MHL", + ATTR_INPUT_SOURCE_LIST: sources, + }, + ) + await hass.async_block_till_done() + acc = ReceiverMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 34 # Receiver diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr new file mode 100644 index 00000000000000..4c408f2887e3ea --- /dev/null +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -0,0 +1,12712 @@ +# serializer version: 1 +# name: test_snapshots[airversa_ap2] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0.1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Sleekpoint Innovations', + 'model': 'AP2', + 'name': 'Airversa AP2 1808', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.16', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.airversa_ap2_1808_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Identify', + }), + 'entity_id': 'button.airversa_ap2_1808_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.airversa_ap2_1808_provision_preferred_thread_credentials', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Provision Preferred Thread Credentials', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_112_119', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Provision Preferred Thread Credentials', + }), + 'entity_id': 'button.airversa_ap2_1808_provision_preferred_thread_credentials', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airversa_ap2_1808_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Air Quality', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2576_2579', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'aqi', + 'friendly_name': 'Airversa AP2 1808 Air Quality', + 'state_class': , + }), + 'entity_id': 'sensor.airversa_ap2_1808_air_quality', + 'state': '1', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airversa_ap2_1808_filter_lifetime', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Filter lifetime', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32896_32900', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Filter lifetime', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.airversa_ap2_1808_filter_lifetime', + 'state': '100.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airversa_ap2_1808_pm2_5_density', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 PM2.5 Density', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2576_2580', + 'unit_of_measurement': 'µg/m³', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'pm25', + 'friendly_name': 'Airversa AP2 1808 PM2.5 Density', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'entity_id': 'sensor.airversa_ap2_1808_pm2_5_density', + 'state': '3.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airversa_ap2_1808_thread_capabilities', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Thread Capabilities', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_node_capabilities', + 'unique_id': '00:00:00:00:00:00_1_112_115', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Airversa AP2 1808 Thread Capabilities', + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'entity_id': 'sensor.airversa_ap2_1808_thread_capabilities', + 'state': 'router_eligible', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airversa_ap2_1808_thread_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Thread Status', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_status', + 'unique_id': '00:00:00:00:00:00_1_112_117', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Airversa AP2 1808 Thread Status', + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'entity_id': 'sensor.airversa_ap2_1808_thread_status', + 'state': 'router', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airversa_ap2_1808_lock_physical_controls', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Airversa AP2 1808 Lock Physical Controls', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32832_32839', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Lock Physical Controls', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.airversa_ap2_1808_lock_physical_controls', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airversa_ap2_1808_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'Airversa AP2 1808 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32832_32843', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.airversa_ap2_1808_mute', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airversa_ap2_1808_sleep_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:power-sleep', + 'original_name': 'Airversa AP2 1808 Sleep Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32832_32842', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Sleep Mode', + 'icon': 'mdi:power-sleep', + }), + 'entity_id': 'switch.airversa_ap2_1808_sleep_mode', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[anker_eufycam] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '2.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8010', + 'name': 'eufy HomeBase2-0AAA', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.1.6', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufy_homebase2_0aaa_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufy HomeBase2-0AAA Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufy HomeBase2-0AAA Identify', + }), + 'entity_id': 'button.eufy_homebase2_0aaa_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8113', + 'name': 'eufyCam2-0000', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.6.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.eufycam2_0000_motion_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'eufyCam2-0000 Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'eufyCam2-0000 Motion Sensor', + }), + 'entity_id': 'binary_sensor.eufycam2_0000_motion_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufycam2_0000_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-0000 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-0000 Identify', + }), + 'entity_id': 'button.eufycam2_0000_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.eufycam2_0000', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-0000', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-0000', + 'supported_features': , + }), + 'entity_id': 'camera.eufycam2_0000', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eufycam2_0000_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'eufyCam2-0000 Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_101', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'eufyCam2-0000 Battery', + 'icon': 'mdi:battery-20', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eufycam2_0000_battery', + 'state': '17', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eufycam2_0000_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'eufyCam2-0000 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_80_83', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-0000 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.eufycam2_0000_mute', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8113', + 'name': 'eufyCam2-000A', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.6.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'eufyCam2-000A Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'eufyCam2-000A Motion Sensor', + }), + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufycam2_000a_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Identify', + }), + 'entity_id': 'button.eufycam2_000a_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.eufycam2_000a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A', + 'supported_features': , + }), + 'entity_id': 'camera.eufycam2_000a', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eufycam2_000a_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'eufyCam2-000A Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_101', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'eufyCam2-000A Battery', + 'icon': 'mdi:battery-40', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eufycam2_000a_battery', + 'state': '38', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eufycam2_000a_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'eufyCam2-000A Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_80_83', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.eufycam2_000a_mute', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8113', + 'name': 'eufyCam2-000A', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.6.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'eufyCam2-000A Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'eufyCam2-000A Motion Sensor', + }), + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor_2', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufycam2_000a_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Identify', + }), + 'entity_id': 'button.eufycam2_000a_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.eufycam2_000a_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A', + 'supported_features': , + }), + 'entity_id': 'camera.eufycam2_000a_2', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eufycam2_000a_battery_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'eufyCam2-000A Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_101', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'eufyCam2-000A Battery', + 'icon': 'mdi:battery-alert', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eufycam2_000a_battery_2', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eufycam2_000a_mute_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'eufyCam2-000A Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_80_83', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.eufycam2_000a_mute_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[aqara_e1] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'HE1-G01', + 'name': 'Aqara-Hub-E1-00A0', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.3.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.aqara_hub_e1_00a0_security_system', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:security', + 'original_name': 'Aqara-Hub-E1-00A0 Security System', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'changed_by': None, + 'code_arm_required': True, + 'code_format': None, + 'friendly_name': 'Aqara-Hub-E1-00A0 Security System', + 'icon': 'mdi:security', + 'supported_features': , + }), + 'entity_id': 'alarm_control_panel.aqara_hub_e1_00a0_security_system', + 'state': 'disarmed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_hub_e1_00a0_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aqara-Hub-E1-00A0 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_65537', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara-Hub-E1-00A0 Identify', + }), + 'entity_id': 'button.aqara_hub_e1_00a0_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqara_hub_e1_00a0_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-high', + 'original_name': 'Aqara-Hub-E1-00A0 Volume', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17_1114116', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara-Hub-E1-00A0 Volume', + 'icon': 'mdi:volume-high', + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.aqara_hub_e1_00a0_volume', + 'state': '40', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.aqara_hub_e1_00a0_pairing_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Aqara-Hub-E1-00A0 Pairing Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17_1114117', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara-Hub-E1-00A0 Pairing Mode', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.aqara_hub_e1_00a0_pairing_mode', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:33', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'AS006', + 'name': 'Contact Sensor', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.contact_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Contact Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_33_4', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Contact Sensor', + }), + 'entity_id': 'binary_sensor.contact_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.contact_sensor_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Contact Sensor Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_33_1_65537', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Contact Sensor Identify', + }), + 'entity_id': 'button.contact_sensor_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.contact_sensor_battery_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Contact Sensor Battery Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_33_5', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Contact Sensor Battery Sensor', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.contact_sensor_battery_sensor', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[aqara_gateway] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'ZHWA11LM', + 'name': 'Aqara Hub-1563', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.4.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.aqara_hub_1563_security_system', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:security', + 'original_name': 'Aqara Hub-1563 Security System', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_66304', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'changed_by': None, + 'code_arm_required': True, + 'code_format': None, + 'friendly_name': 'Aqara Hub-1563 Security System', + 'icon': 'mdi:security', + 'supported_features': , + }), + 'entity_id': 'alarm_control_panel.aqara_hub_1563_security_system', + 'state': 'disarmed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_hub_1563_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aqara Hub-1563 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Identify', + }), + 'entity_id': 'button.aqara_hub_1563_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.aqara_hub_1563_lightbulb_1563', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aqara Hub-1563 Lightbulb-1563', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_65792', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Lightbulb-1563', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.aqara_hub_1563_lightbulb_1563', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqara_hub_1563_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-high', + 'original_name': 'Aqara Hub-1563 Volume', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_65536_65541', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Volume', + 'icon': 'mdi:volume-high', + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.aqara_hub_1563_volume', + 'state': '40', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.aqara_hub_1563_pairing_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Aqara Hub-1563 Pairing Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_65536_65538', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Pairing Mode', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.aqara_hub_1563_pairing_mode', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[aqara_switch] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'AR004', + 'name': 'Programmable Switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '9', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.programmable_switch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmable Switch Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_65537', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Programmable Switch Identify', + }), + 'entity_id': 'button.programmable_switch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.programmable_switch_battery_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Programmable Switch Battery Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_5', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Programmable Switch Battery Sensor', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.programmable_switch_battery_sensor', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[arlo_baby] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netgear, Inc', + 'model': 'ABC1000', + 'name': 'ArloBabyA0', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.10.931', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.arlobabya0_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Motion', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_500', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'ArloBabyA0 Motion', + }), + 'entity_id': 'binary_sensor.arlobabya0_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.arlobabya0_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ArloBabyA0 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Identify', + }), + 'entity_id': 'button.arlobabya0_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.arlobabya0', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ArloBabyA0', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0', + 'supported_features': , + }), + 'entity_id': 'camera.arlobabya0', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.arlobabya0_nightlight', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ArloBabyA0 Nightlight', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1100', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Nightlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.arlobabya0_nightlight', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.arlobabya0_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Air Quality', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_800_802', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'aqi', + 'friendly_name': 'ArloBabyA0 Air Quality', + 'state_class': , + }), + 'entity_id': 'sensor.arlobabya0_air_quality', + 'state': '1', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.arlobabya0_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'ArloBabyA0 Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_700', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'ArloBabyA0 Battery', + 'icon': 'mdi:battery-80', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.arlobabya0_battery', + 'state': '82', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.arlobabya0_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_900', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'ArloBabyA0 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.arlobabya0_humidity', + 'state': '60.099998', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.arlobabya0_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1000', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'ArloBabyA0 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.arlobabya0_temperature', + 'state': '24.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.arlobabya0_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'ArloBabyA0 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_300_302', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.arlobabya0_mute', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.arlobabya0_mute_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'ArloBabyA0 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_400_402', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.arlobabya0_mute_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[connectsense] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ConnectSense', + 'model': 'CS-IWO', + 'name': 'InWall Outlet-0394DE', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.inwall_outlet_0394de_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'InWall Outlet-0394DE Identify', + }), + 'entity_id': 'button.inwall_outlet_0394de_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_current', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Current', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13_18', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'current', + 'friendly_name': 'InWall Outlet-0394DE Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_current', + 'state': '0.03', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_current_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Current', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25_30', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'current', + 'friendly_name': 'InWall Outlet-0394DE Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_current_2', + 'state': '0.05', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Energy kWh', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13_20', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'InWall Outlet-0394DE Energy kWh', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh', + 'state': '379.69299', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Energy kWh', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25_32', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'InWall Outlet-0394DE Energy kWh', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh_2', + 'state': '175.85001', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'InWall Outlet-0394DE Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_power', + 'state': '0.8', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_power_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25_31', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'InWall Outlet-0394DE Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_power_2', + 'state': '0.8', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.inwall_outlet_0394de_outlet_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Outlet A', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'InWall Outlet-0394DE Outlet A', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.inwall_outlet_0394de_outlet_a', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.inwall_outlet_0394de_outlet_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Outlet B', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'InWall Outlet-0394DE Outlet B', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.inwall_outlet_0394de_outlet_b', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee3] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Basement', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement', + }), + 'entity_id': 'binary_sensor.basement', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_4101', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Basement Identify', + }), + 'entity_id': 'button.basement_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.basement_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Basement Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.basement_temperature', + 'state': '20.7', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3', + 'name': 'HomeW', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.2.394', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew_2', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.homew_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Clear Hold', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Clear Hold', + }), + 'entity_id': 'button.homew_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.homew_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Identify', + }), + 'entity_id': 'button.homew_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 21.8, + 'friendly_name': 'HomeW', + 'humidity': 36, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.2, + }), + 'entity_id': 'climate.homew', + 'state': 'heat', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.homew_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Current Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.homew_current_mode', + 'state': 'home', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.homew_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'HomeW Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.homew_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'HomeW Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.homew_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'HomeW Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.homew_current_temperature', + 'state': '21.8', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Kitchen', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kitchen', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Kitchen', + }), + 'entity_id': 'binary_sensor.kitchen', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.kitchen_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2053', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Kitchen Identify', + }), + 'entity_id': 'button.kitchen_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Kitchen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.kitchen_temperature', + 'state': '21.5', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Porch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.porch', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Porch', + }), + 'entity_id': 'binary_sensor.porch', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.porch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Porch Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_3077', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Porch Identify', + }), + 'entity_id': 'button.porch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.porch_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Porch Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.porch_temperature', + 'state': '21', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee3_no_sensors] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3', + 'name': 'HomeW', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.2.394', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew_2', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.homew_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Clear Hold', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Clear Hold', + }), + 'entity_id': 'button.homew_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.homew_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Identify', + }), + 'entity_id': 'button.homew_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 21.8, + 'friendly_name': 'HomeW', + 'humidity': 36, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.2, + }), + 'entity_id': 'climate.homew', + 'state': 'heat', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.homew_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Current Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.homew_current_mode', + 'state': 'home', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.homew_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'HomeW Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.homew_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'HomeW Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.homew_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'HomeW Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.homew_current_temperature', + 'state': '21.8', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee_501] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ECB501', + 'name': 'My ecobee', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.7.340214', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_ecobee_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Motion', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'My ecobee Motion', + }), + 'entity_id': 'binary_sensor.my_ecobee_motion', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_ecobee_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Occupancy', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'My ecobee Occupancy', + }), + 'entity_id': 'binary_sensor.my_ecobee_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.my_ecobee_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee Clear Hold', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Clear Hold', + }), + 'entity_id': 'button.my_ecobee_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_ecobee_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Identify', + }), + 'entity_id': 'button.my_ecobee_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.my_ecobee', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 55.0, + 'current_temperature': 21.3, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'friendly_name': 'My ecobee', + 'humidity': 36.0, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': 25.6, + 'target_temp_low': 7.2, + 'temperature': None, + }), + 'entity_id': 'climate.my_ecobee', + 'state': 'heat_cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.my_ecobee_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee Current Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.my_ecobee_current_mode', + 'state': 'home', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.my_ecobee_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'My ecobee Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.my_ecobee_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_ecobee_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'My ecobee Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.my_ecobee_current_humidity', + 'state': '55.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_ecobee_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'My ecobee Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.my_ecobee_current_temperature', + 'state': '21.3', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee_occupancy] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee Switch+', + 'name': 'Master Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.5.130201', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Master Fan', + }), + 'entity_id': 'binary_sensor.master_fan', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_fan_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Master Fan', + }), + 'entity_id': 'binary_sensor.master_fan_2', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Fan Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Master Fan Identify', + }), + 'entity_id': 'button.master_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.master_fan_light_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan Light Level', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_27', + 'unit_of_measurement': 'lx', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'illuminance', + 'friendly_name': 'Master Fan Light Level', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'entity_id': 'sensor.master_fan_light_level', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.master_fan_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Master Fan Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.master_fan_temperature', + 'state': '25.6', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.master_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Fan', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Master Fan', + }), + 'entity_id': 'switch.master_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[eve_degree] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Eve Degree 00AAA0000', + 'name': 'Eve Degree AA11', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.2.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eve_degree_aa11_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Degree AA11 Identify', + }), + 'entity_id': 'button.eve_degree_aa11_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 9000, + 'min': -450, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.eve_degree_aa11_elevation', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:elevation-rise', + 'original_name': 'Eve Degree AA11 Elevation', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Degree AA11 Elevation', + 'icon': 'mdi:elevation-rise', + 'max': 9000, + 'min': -450, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.eve_degree_aa11_elevation', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.eve_degree_aa11_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'Eve Degree AA11 Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_22_25', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Degree AA11 Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.eve_degree_aa11_temperature_display_units', + 'state': 'celsius', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_degree_aa11_air_pressure', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Air Pressure', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_32', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'pressure', + 'friendly_name': 'Eve Degree AA11 Air Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_degree_aa11_air_pressure', + 'state': '1005.70001220703', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_degree_aa11_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Eve Degree AA11 Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Eve Degree AA11 Battery', + 'icon': 'mdi:battery-60', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eve_degree_aa11_battery', + 'state': '65', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_degree_aa11_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_27', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Eve Degree AA11 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eve_degree_aa11_humidity', + 'state': '59.4818115234375', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_degree_aa11_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_22', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Eve Degree AA11 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_degree_aa11_temperature', + 'state': '22.7719116210938', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[eve_energy] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Eve Energy 20EAO8601', + 'name': 'Eve Energy 50FF', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.2.9', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eve_energy_50ff_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Energy 50FF Identify', + }), + 'entity_id': 'button.eve_energy_50ff_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_amps', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Amps', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_33', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'current', + 'friendly_name': 'Eve Energy 50FF Amps', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_amps', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_energy_kwh', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Energy kWh', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_35', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'Eve Energy 50FF Energy kWh', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_energy_kwh', + 'state': '0.28999999165535', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_34', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Eve Energy 50FF Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_power', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_volts', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Volts', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_32', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'voltage', + 'friendly_name': 'Eve Energy 50FF Volts', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_volts', + 'state': '0.400000005960464', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.eve_energy_50ff', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eve Energy 50FF', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Energy 50FF', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.eve_energy_50ff', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eve_energy_50ff_lock_physical_controls', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Eve Energy 50FF Lock Physical Controls', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_36', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Energy 50FF Lock Physical Controls', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.eve_energy_50ff_lock_physical_controls', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[haa_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'José A. Jiménez Campos', + 'model': 'RavenSystem HAA', + 'name': 'HAA-C718B3', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '5.0.18', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3 Identify', + }), + 'entity_id': 'button.haa_c718b3_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_setup', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'HAA-C718B3 Setup', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1010_1012', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3 Setup', + 'icon': 'mdi:cog', + }), + 'entity_id': 'button.haa_c718b3_setup', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_update', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HAA-C718B3 Update', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1010_1011', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'update', + 'friendly_name': 'HAA-C718B3 Update', + }), + 'entity_id': 'button.haa_c718b3_update', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.haa_c718b3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3', + 'percentage': 66, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.haa_c718b3', + 'state': 'on', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'José A. Jiménez Campos', + 'model': 'RavenSystem HAA', + 'name': 'HAA-C718B3', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '5.0.18', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3 Identify', + }), + 'entity_id': 'button.haa_c718b3_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.haa_c718b3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3', + }), + 'entity_id': 'switch.haa_c718b3', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:766313939', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Ceiling Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ceiling_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan Identify', + }), + 'entity_id': 'button.ceiling_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ceiling_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan', + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.ceiling_fan', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'Home Assistant Bridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.home_assistant_bridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home Assistant Bridge Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Home Assistant Bridge Identify', + }), + 'entity_id': 'button.home_assistant_bridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1256851357', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Living Room Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Living Room Fan Identify', + }), + 'entity_id': 'button.living_room_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living_room_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'Living Room Fan', + 'oscillating': False, + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.living_room_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[homespan_daikin_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Garzola Marco', + 'model': 'Daikin-fwec3a-esp32-homekit-bridge', + 'name': 'Air Conditioner', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.air_conditioner_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Conditioner Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Air Conditioner Identify', + }), + 'entity_id': 'button.air_conditioner_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'target_temp_step': 0.5, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioner_slaveid_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Conditioner SlaveID 1', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_9', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_temperature': 27.9, + 'fan_mode': 'high', + 'fan_modes': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Air Conditioner SlaveID 1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 24.5, + }), + 'entity_id': 'climate.air_conditioner_slaveid_1', + 'state': 'cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioner_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air Conditioner Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_9_11', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Conditioner Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.air_conditioner_current_temperature', + 'state': '27.9', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[hue_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462395276914', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276914_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify_4', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276914_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle_4', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462395276939', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276939_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify_3', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276939_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle_3', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462403113447', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403113447_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403113447_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle_2', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462403233419', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403233419_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403233419_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462412411853', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW013', + 'name': 'Hue ambiance spot', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_spot_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412411853_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance spot Identify', + }), + 'entity_id': 'button.hue_ambiance_spot_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_spot_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412411853_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 255.0, + 'color_mode': , + 'color_temp': 366, + 'color_temp_kelvin': 2732, + 'friendly_name': 'Hue ambiance spot', + 'hs_color': tuple( + 28.327, + 64.71, + ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 167, + 89, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.524, + 0.387, + ), + }), + 'entity_id': 'light.hue_ambiance_spot_2', + 'state': 'on', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462412413293', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW013', + 'name': 'Hue ambiance spot', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_spot_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412413293_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance spot Identify', + }), + 'entity_id': 'button.hue_ambiance_spot_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_spot', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412413293_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 255.0, + 'color_mode': , + 'color_temp': 366, + 'color_temp_kelvin': 2732, + 'friendly_name': 'Hue ambiance spot', + 'hs_color': tuple( + 28.327, + 64.71, + ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 167, + 89, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.524, + 0.387, + ), + }), + 'entity_id': 'light.hue_ambiance_spot', + 'state': 'on', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462389072572', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'RWL021', + 'name': 'Hue dimmer switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '45.1.17846', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_dimmer_switch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue dimmer switch Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462389072572_1_22', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue dimmer switch Identify', + }), + 'entity_id': 'button.hue_dimmer_switch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 1', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410585088', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 1', + }), + 'entity_id': 'event.hue_dimmer_switch_button_1', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 2', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410650624', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 2', + }), + 'entity_id': 'event.hue_dimmer_switch_button_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 3', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410716160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 3', + }), + 'entity_id': 'event.hue_dimmer_switch_button_3', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 4', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410781696', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 4', + }), + 'entity_id': 'event.hue_dimmer_switch_button_4', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hue_dimmer_switch_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Hue dimmer switch battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462389072572_644245094400', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Hue dimmer switch battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.hue_dimmer_switch_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462378982941', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378982941_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378982941_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462378983942', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378983942_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378983942_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_2', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462379122122', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379122122_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_4', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379122122_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_4', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462379123707', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379123707_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_3', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379123707_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_3', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462383114163', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_7', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114163_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_7', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_7', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114163_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_7', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462383114193', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_6', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114193_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_6', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_6', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114193_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_6', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462385996792', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462385996792_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_5', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462385996792_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_5', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips Lighting', + 'model': 'BSB002', + 'name': 'Philips hue - 482544', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.32.1932126170', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.philips_hue_482544_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Philips hue - 482544 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Philips hue - 482544 Identify', + }), + 'entity_id': 'button.philips_hue_482544_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[koogeek_ls1] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Koogeek', + 'model': 'LS1', + 'name': 'Koogeek-LS1-20833F', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.2.15', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Identify', + }), + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Light Strip', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Light Strip', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[koogeek_p1eu] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Koogeek', + 'model': 'P1EU', + 'name': 'Koogeek-P1-A00AA0', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.3.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.koogeek_p1_a00aa0_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-P1-A00AA0 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-P1-A00AA0 Identify', + }), + 'entity_id': 'button.koogeek_p1_a00aa0_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.koogeek_p1_a00aa0_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Koogeek-P1-A00AA0 Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_21_22', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Koogeek-P1-A00AA0 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.koogeek_p1_a00aa0_power', + 'state': '5', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.koogeek_p1_a00aa0_outlet', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-P1-A00AA0 outlet', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-P1-A00AA0 outlet', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.koogeek_p1_a00aa0_outlet', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[koogeek_sw2] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Koogeek', + 'model': 'KH02CN', + 'name': 'Koogeek-SW2-187A91', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.3', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.koogeek_sw2_187a91_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-SW2-187A91 Identify', + }), + 'entity_id': 'button.koogeek_sw2_187a91_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.koogeek_sw2_187a91_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_14_18', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Koogeek-SW2-187A91 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.koogeek_sw2_187a91_power', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.koogeek_sw2_187a91_switch_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Switch 1', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-SW2-187A91 Switch 1', + }), + 'entity_id': 'switch.koogeek_sw2_187a91_switch_1', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.koogeek_sw2_187a91_switch_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Switch 2', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_11', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-SW2-187A91 Switch 2', + }), + 'entity_id': 'switch.koogeek_sw2_187a91_switch_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[lennox_e30] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '3.0.XX', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lennox', + 'model': 'E30 2B', + 'name': 'Lennox', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.40.XX', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lennox_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lennox Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Lennox Identify', + }), + 'entity_id': 'button.lennox_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 37, + 'min_temp': 4.5, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.lennox', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lennox', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_100', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 20.5, + 'friendly_name': 'Lennox', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 37, + 'min_temp': 4.5, + 'supported_features': , + 'target_temp_high': 29.5, + 'target_temp_low': 21, + 'temperature': None, + }), + 'entity_id': 'climate.lennox', + 'state': 'heat_cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.lennox_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'Lennox Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_100_105', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Lennox Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.lennox_temperature_display_units', + 'state': 'celsius', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lennox_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lennox Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_100_107', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Lennox Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.lennox_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lennox_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lennox Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_100_103', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Lennox Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.lennox_current_temperature', + 'state': '20.5', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[lg_tv] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'LG Electronics', + 'model': 'OLED55B9PUA', + 'name': 'LG webOS TV AF80', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '04.71.04', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lg_webos_tv_af80_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LG webOS TV AF80 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LG webOS TV AF80 Identify', + }), + 'entity_id': 'button.lg_webos_tv_af80_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'AirPlay', + 'Live TV', + 'HDMI 1', + 'Sony', + 'Apple', + 'AV', + 'HDMI 4', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.lg_webos_tv_af80', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'LG webOS TV AF80', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'tv', + 'friendly_name': 'LG webOS TV AF80', + 'source': 'HDMI 4', + 'source_list': list([ + 'AirPlay', + 'Live TV', + 'HDMI 1', + 'Sony', + 'Apple', + 'AV', + 'HDMI 4', + ]), + 'supported_features': , + }), + 'entity_id': 'media_player.lg_webos_tv_af80', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.lg_webos_tv_af80_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'LG webOS TV AF80 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_80_82', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LG webOS TV AF80 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.lg_webos_tv_af80_mute', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[lutron_caseta_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:21474836482', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lutron Electronics Co., Inc', + 'model': 'PD-FSQN-XX', + 'name': 'Caséta® Wireless Fan Speed Control', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '001.005', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.caseta_r_wireless_fan_speed_control_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Caséta® Wireless Fan Speed Control Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_21474836482_1_85899345921', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Caséta® Wireless Fan Speed Control Identify', + }), + 'entity_id': 'button.caseta_r_wireless_fan_speed_control_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.caseta_r_wireless_fan_speed_control', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Caséta® Wireless Fan Speed Control', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_21474836482_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Caséta® Wireless Fan Speed Control', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.caseta_r_wireless_fan_speed_control', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lutron Electronics Co., Inc', + 'model': 'L-BDG2-WH', + 'name': 'Smart Bridge 2', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '08.08', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.smart_bridge_2_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart Bridge 2 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_85899345921', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Smart Bridge 2 Identify', + }), + 'entity_id': 'button.smart_bridge_2_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[mss425f] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '4.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Meross', + 'model': 'MSS425F', + 'name': 'MSS425F-15cc', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.2.3', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mss425f_15cc_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Identify', + }), + 'entity_id': 'button.mss425f_15cc_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-1', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_12', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-1', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_1', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-2', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_15', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-2', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_2', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-3', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_18', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-3', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_3', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-4', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-4', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_4', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_usb', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc USB', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_24', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc USB', + }), + 'entity_id': 'switch.mss425f_15cc_usb', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[mss565] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '4.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Meross', + 'model': 'MSS565', + 'name': 'MSS565-28da', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.1.9', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mss565_28da_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS565-28da Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS565-28da Identify', + }), + 'entity_id': 'button.mss565_28da_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mss565_28da_dimmer_switch', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS565-28da Dimmer Switch', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_12', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 170.85, + 'color_mode': , + 'friendly_name': 'MSS565-28da Dimmer Switch', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.mss565_28da_dimmer_switch', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[mysa_living] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Empowered Homes Inc.', + 'model': 'v1', + 'name': 'Mysa-85dda9', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.8.1', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mysa_85dda9_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Mysa-85dda9 Identify', + }), + 'entity_id': 'button.mysa_85dda9_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mysa_85dda9_thermostat', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Thermostat', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 40, + 'current_temperature': 24.1, + 'friendly_name': 'Mysa-85dda9 Thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': None, + }), + 'entity_id': 'climate.mysa_85dda9_thermostat', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mysa_85dda9_display', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Display', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_40', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Mysa-85dda9 Display', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.mysa_85dda9_display', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mysa_85dda9_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'Mysa-85dda9 Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_20_26', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Mysa-85dda9 Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.mysa_85dda9_temperature_display_units', + 'state': 'celsius', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mysa_85dda9_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20_27', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Mysa-85dda9 Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.mysa_85dda9_current_humidity', + 'state': '40', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mysa_85dda9_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20_25', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Mysa-85dda9 Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.mysa_85dda9_current_temperature', + 'state': '24.1', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[nanoleaf_strip_nl55] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.2.4', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Nanoleaf', + 'model': 'NL55', + 'name': 'Nanoleaf Strip 3B32', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.4.40', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.nanoleaf_strip_3b32_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Nanoleaf Strip 3B32 Identify', + }), + 'entity_id': 'button.nanoleaf_strip_3b32_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.nanoleaf_strip_3b32_provision_preferred_thread_credentials', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Provision Preferred Thread Credentials', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_31_119', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Nanoleaf Strip 3B32 Provision Preferred Thread Credentials', + }), + 'entity_id': 'button.nanoleaf_strip_3b32_provision_preferred_thread_credentials', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 470, + 'min_color_temp_kelvin': 2127, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.nanoleaf_strip_3b32_nanoleaf_light_strip', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Nanoleaf Light Strip', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_19', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 255.0, + 'color_mode': , + 'friendly_name': 'Nanoleaf Strip 3B32 Nanoleaf Light Strip', + 'hs_color': tuple( + 30.0, + 89.0, + ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 470, + 'min_color_temp_kelvin': 2127, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 141, + 28, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.589, + 0.385, + ), + }), + 'entity_id': 'light.nanoleaf_strip_3b32_nanoleaf_light_strip', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_capabilities', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Thread Capabilities', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_node_capabilities', + 'unique_id': '00:00:00:00:00:00_1_31_115', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Nanoleaf Strip 3B32 Thread Capabilities', + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_capabilities', + 'state': 'border_router_capable', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Thread Status', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_status', + 'unique_id': '00:00:00:00:00:00_1_31_117', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Nanoleaf Strip 3B32 Thread Status', + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_status', + 'state': 'border_router', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[netamo_doorbell] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Netatmo Doorbell', + 'name': 'Netatmo-Doorbell-g738658', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '80.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.netatmo_doorbell_g738658_motion_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658 Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_10', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Netatmo-Doorbell-g738658 Motion Sensor', + }), + 'entity_id': 'binary_sensor.netatmo_doorbell_g738658_motion_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.netatmo_doorbell_g738658_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658 Identify', + }), + 'entity_id': 'button.netatmo_doorbell_g738658_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.netatmo_doorbell_g738658', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658', + 'supported_features': , + }), + 'entity_id': 'camera.netatmo_doorbell_g738658', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + 'double_press', + 'long_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.netatmo_doorbell_g738658', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'doorbell', + 'unique_id': '00:00:00:00:00:00_1_49', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'doorbell', + 'event_type': None, + 'event_types': list([ + 'single_press', + 'double_press', + 'long_press', + ]), + 'friendly_name': 'Netatmo-Doorbell-g738658', + }), + 'entity_id': 'event.netatmo_doorbell_g738658', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.netatmo_doorbell_g738658_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'Netatmo-Doorbell-g738658 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_51_52', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.netatmo_doorbell_g738658_mute', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.netatmo_doorbell_g738658_mute_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'Netatmo-Doorbell-g738658 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8_9', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.netatmo_doorbell_g738658_mute_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[netamo_smart_co_alarm] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart CO Alarm', + 'name': 'Smart CO Alarm', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.3', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.smart_co_alarm_carbon_monoxide_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smart CO Alarm Carbon Monoxide Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_22', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'carbon_monoxide', + 'friendly_name': 'Smart CO Alarm Carbon Monoxide Sensor', + }), + 'entity_id': 'binary_sensor.smart_co_alarm_carbon_monoxide_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.smart_co_alarm_low_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smart CO Alarm Low Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_36', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Smart CO Alarm Low Battery', + }), + 'entity_id': 'binary_sensor.smart_co_alarm_low_battery', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.smart_co_alarm_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart CO Alarm Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_7_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Smart CO Alarm Identify', + }), + 'entity_id': 'button.smart_co_alarm_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[netatmo_home_coach] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Healthy Home Coach', + 'name': 'Healthy Home Coach', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '59', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.healthy_home_coach_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Healthy Home Coach Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Healthy Home Coach Identify', + }), + 'entity_id': 'button.healthy_home_coach_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Air Quality', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_24_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'aqi', + 'friendly_name': 'Healthy Home Coach Air Quality', + 'state_class': , + }), + 'entity_id': 'sensor.healthy_home_coach_air_quality', + 'state': '1', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_carbon_dioxide_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Carbon Dioxide sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_10', + 'unit_of_measurement': 'ppm', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Healthy Home Coach Carbon Dioxide sensor', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.healthy_home_coach_carbon_dioxide_sensor', + 'state': '804', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_humidity_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Humidity sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_14', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Healthy Home Coach Humidity sensor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.healthy_home_coach_humidity_sensor', + 'state': '59', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_noise', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Noise', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20_21', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'sound_pressure', + 'friendly_name': 'Healthy Home Coach Noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.healthy_home_coach_noise', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_temperature_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Temperature sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Healthy Home Coach Temperature sensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.healthy_home_coach_temperature_sensor', + 'state': '22.9', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[rainmachine-pro-8] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Green Electronics LLC', + 'model': 'SPK5 Pro', + 'name': 'RainMachine-00ce4a', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.rainmachine_00ce4a_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RainMachine-00ce4a Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a Identify', + }), + 'entity_id': 'button.rainmachine_00ce4a_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_512', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_768', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_2', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1024', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_3', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1280', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_4', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1536', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_5', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_6', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1792', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_6', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_7', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2048', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_7', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_8', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2304', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_8', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ryse_smart_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'Master Bath South', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_bath_south_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Bath South Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Master Bath South Identify', + }), + 'entity_id': 'button.master_bath_south_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.master_bath_south_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Bath South RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'friendly_name': 'Master Bath South RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.master_bath_south_ryse_shade', + 'state': 'closed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.master_bath_south_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Master Bath South RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Master Bath South RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.master_bath_south_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0101.3521.0436', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE SmartBridge', + 'name': 'RYSE SmartBridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.3.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ryse_smartbridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartBridge Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RYSE SmartBridge Identify', + }), + 'entity_id': 'button.ryse_smartbridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'RYSE SmartShade', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ryse_smartshade_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartShade Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RYSE SmartShade Identify', + }), + 'entity_id': 'button.ryse_smartshade_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.ryse_smartshade_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartShade RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'RYSE SmartShade RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.ryse_smartshade_ryse_shade', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ryse_smartshade_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'RYSE SmartShade RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'RYSE SmartShade RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.ryse_smartshade_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ryse_smart_bridge_four_shades] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'BR Left', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.br_left_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'BR Left Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'BR Left Identify', + }), + 'entity_id': 'button.br_left_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.br_left_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'BR Left RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'BR Left RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.br_left_ryse_shade', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.br_left_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'BR Left RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'BR Left RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.br_left_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'LR Left', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lr_left_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Left Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LR Left Identify', + }), + 'entity_id': 'button.lr_left_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.lr_left_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Left RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'friendly_name': 'LR Left RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.lr_left_ryse_shade', + 'state': 'closed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.lr_left_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'LR Left RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'LR Left RYSE Shade Battery', + 'icon': 'mdi:battery-90', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.lr_left_ryse_shade_battery', + 'state': '89', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'LR Right', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lr_right_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Right Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LR Right Identify', + }), + 'entity_id': 'button.lr_right_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.lr_right_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Right RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'friendly_name': 'LR Right RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.lr_right_ryse_shade', + 'state': 'closed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.lr_right_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'LR Right RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'LR Right RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.lr_right_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0401.3521.0679', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE SmartBridge', + 'name': 'RYSE SmartBridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.3.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ryse_smartbridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartBridge Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RYSE SmartBridge Identify', + }), + 'entity_id': 'button.ryse_smartbridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:5', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'RZSS', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.rzss_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RZSS Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_5_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RZSS Identify', + }), + 'entity_id': 'button.rzss_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.rzss_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RZSS RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_5_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'RZSS RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.rzss_ryse_shade', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rzss_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'RZSS RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_5_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'RZSS RYSE Shade Battery', + 'icon': 'mdi:battery-alert', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.rzss_ryse_shade_battery', + 'state': '0', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[schlage_sense] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.3.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Schlage ', + 'model': 'BE479CAM619', + 'name': 'SENSE ', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '004.027.000', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.sense_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SENSE Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'SENSE Identify', + }), + 'entity_id': 'button.sense_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.sense_lock_mechanism', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SENSE Lock Mechanism', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'SENSE Lock Mechanism', + 'supported_features': , + }), + 'entity_id': 'lock.sense_lock_mechanism', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[simpleconnect_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Hunter Fan', + 'model': 'SIMPLEconnect', + 'name': 'SIMPLEconnect Fan-06F674', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.simpleconnect_fan_06f674_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SIMPLEconnect Fan-06F674 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'SIMPLEconnect Fan-06F674 Identify', + }), + 'entity_id': 'button.simpleconnect_fan_06f674_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.simpleconnect_fan_06f674_hunter_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Fan', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'SIMPLEconnect Fan-06F674 Hunter Fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.simpleconnect_fan_06f674_hunter_fan', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.simpleconnect_fan_06f674_hunter_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Light', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_29', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 76.5, + 'color_mode': , + 'friendly_name': 'SIMPLEconnect Fan-06F674 Hunter Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.simpleconnect_fan_06f674_hunter_light', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[velux_gateway] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VELUX', + 'model': 'VELUX Gateway', + 'name': 'VELUX Gateway', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '70', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.velux_gateway_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VELUX Gateway Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VELUX Gateway Identify', + }), + 'entity_id': 'button.velux_gateway_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VELUX', + 'model': 'VELUX Sensor', + 'name': 'VELUX Sensor', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '16', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.velux_sensor_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VELUX Sensor Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VELUX Sensor Identify', + }), + 'entity_id': 'button.velux_sensor_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.velux_sensor_carbon_dioxide_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Sensor Carbon Dioxide sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_14', + 'unit_of_measurement': 'ppm', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'VELUX Sensor Carbon Dioxide sensor', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.velux_sensor_carbon_dioxide_sensor', + 'state': '400', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.velux_sensor_humidity_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Sensor Humidity sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_11', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'VELUX Sensor Humidity sensor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.velux_sensor_humidity_sensor', + 'state': '58', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.velux_sensor_temperature_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Sensor Temperature sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_8', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'VELUX Sensor Temperature sensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.velux_sensor_temperature_sensor', + 'state': '18.9', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VELUX', + 'model': 'VELUX Window', + 'name': 'VELUX Window', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '48', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.velux_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VELUX Window Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VELUX Window Identify', + }), + 'entity_id': 'button.velux_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.velux_window_roof_window', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Window Roof Window', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'device_class': 'window', + 'friendly_name': 'VELUX Window Roof Window', + 'supported_features': , + }), + 'entity_id': 'cover.velux_window_roof_window', + 'state': 'closed', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[vocolinc_flowerbud] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0.1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VOCOlinc', + 'model': 'Flowerbud', + 'name': 'VOCOlinc-Flowerbud-0d324b', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.121.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vocolinc_flowerbud_0d324b_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Identify', + }), + 'entity_id': 'button.vocolinc_flowerbud_0d324b_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.vocolinc_flowerbud_0d324b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 45.0, + 'device_class': 'humidifier', + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b', + 'humidity': 100.0, + 'max_humidity': 100, + 'min_humidity': 0, + 'mode': 'normal', + 'supported_features': , + }), + 'entity_id': 'humidifier.vocolinc_flowerbud_0d324b', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.vocolinc_flowerbud_0d324b_mood_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_9', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 127.5, + 'color_mode': , + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', + 'hs_color': tuple( + 120.0, + 100.0, + ), + 'rgb_color': tuple( + 0, + 255, + 0, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.172, + 0.747, + ), + }), + 'entity_id': 'light.vocolinc_flowerbud_0d324b_mood_light', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.vocolinc_flowerbud_0d324b_spray_quantity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'VOCOlinc-Flowerbud-0d324b Spray Quantity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_38', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Spray Quantity', + 'icon': 'mdi:water', + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.vocolinc_flowerbud_0d324b_spray_quantity', + 'state': '5', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vocolinc_flowerbud_0d324b_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_33', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.vocolinc_flowerbud_0d324b_current_humidity', + 'state': '45.0', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[vocolinc_vp3] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.3', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VOCOlinc', + 'model': 'VP3', + 'name': 'VOCOlinc-VP3-123456', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.101.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vocolinc_vp3_123456_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-VP3-123456 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-VP3-123456 Identify', + }), + 'entity_id': 'button.vocolinc_vp3_123456_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vocolinc_vp3_123456_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VOCOlinc-VP3-123456 Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_48_97', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'VOCOlinc-VP3-123456 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.vocolinc_vp3_123456_power', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.vocolinc_vp3_123456_outlet', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-VP3-123456 Outlet', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-VP3-123456 Outlet', + }), + 'entity_id': 'switch.vocolinc_vp3_123456_outlet', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- diff --git a/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py b/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py deleted file mode 100644 index 0091fc098ded5a..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Tests for Airversa AP2 Air Purifier.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_airversa_ap2_setup(hass: HomeAssistant) -> None: - """Test that an Ecbobee occupancy sensor be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "airversa_ap2.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Airversa AP2 1808", - model="AP2", - manufacturer="Sleekpoint Innovations", - sw_version="0.8.16", - hw_version="0.1", - serial_number="1234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.airversa_ap2_1808_lock_physical_controls", - friendly_name="Airversa AP2 1808 Lock Physical Controls", - unique_id="00:00:00:00:00:00_1_32832_32839", - entity_category=EntityCategory.CONFIG, - state="off", - ), - EntityTestInfo( - entity_id="switch.airversa_ap2_1808_mute", - friendly_name="Airversa AP2 1808 Mute", - unique_id="00:00:00:00:00:00_1_32832_32843", - entity_category=EntityCategory.CONFIG, - state="on", - ), - EntityTestInfo( - entity_id="switch.airversa_ap2_1808_sleep_mode", - friendly_name="Airversa AP2 1808 Sleep Mode", - unique_id="00:00:00:00:00:00_1_32832_32842", - entity_category=EntityCategory.CONFIG, - state="off", - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_air_quality", - friendly_name="Airversa AP2 1808 Air Quality", - unique_id="00:00:00:00:00:00_1_2576_2579", - state="1", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_filter_lifetime", - friendly_name="Airversa AP2 1808 Filter lifetime", - unique_id="00:00:00:00:00:00_1_32896_32900", - state="100.0", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_pm2_5_density", - friendly_name="Airversa AP2 1808 PM2.5 Density", - unique_id="00:00:00:00:00:00_1_2576_2580", - state="3.0", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_thread_capabilities", - friendly_name="Airversa AP2 1808 Thread Capabilities", - unique_id="00:00:00:00:00:00_1_112_115", - state="router_eligible", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router_capable", - "full", - "minimal", - "none", - "router_eligible", - "sleepy", - ] - }, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_thread_status", - friendly_name="Airversa AP2 1808 Thread Status", - unique_id="00:00:00:00:00:00_1_112_117", - state="router", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router", - "child", - "detached", - "disabled", - "joining", - "leader", - "router", - ] - }, - ), - EntityTestInfo( - entity_id="button.airversa_ap2_1808_identify", - friendly_name="Airversa AP2 1808 Identify", - unique_id="00:00:00:00:00:00_1_1_2", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py deleted file mode 100644 index 30ecc298d40a02..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Regression tests for Aqara Gateway V3. - -https://github.com/home-assistant/core/issues/20957 -""" -from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature -from homeassistant.components.number import NumberMode -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_aqara_gateway_setup(hass: HomeAssistant) -> None: - """Test that a Aqara Gateway can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "aqara_gateway.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Aqara Hub-1563", - model="ZHWA11LM", - manufacturer="Aqara", - sw_version="1.4.7", - hw_version="", - serial_number="0000000123456789", - devices=[], - entities=[ - EntityTestInfo( - "alarm_control_panel.aqara_hub_1563_security_system", - friendly_name="Aqara Hub-1563 Security System", - unique_id="00:00:00:00:00:00_1_66304", - supported_features=AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY, - state="disarmed", - ), - EntityTestInfo( - "light.aqara_hub_1563_lightbulb_1563", - friendly_name="Aqara Hub-1563 Lightbulb-1563", - unique_id="00:00:00:00:00:00_1_65792", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="off", - ), - EntityTestInfo( - "number.aqara_hub_1563_volume", - friendly_name="Aqara Hub-1563 Volume", - unique_id="00:00:00:00:00:00_1_65536_65541", - capabilities={ - "max": 100, - "min": 0, - "mode": NumberMode.AUTO, - "step": 1, - }, - entity_category=EntityCategory.CONFIG, - state="40", - ), - EntityTestInfo( - "switch.aqara_hub_1563_pairing_mode", - friendly_name="Aqara Hub-1563 Pairing Mode", - unique_id="00:00:00:00:00:00_1_65536_65538", - entity_category=EntityCategory.CONFIG, - state="off", - ), - ], - ), - ) - - -async def test_aqara_gateway_e1_setup(hass: HomeAssistant) -> None: - """Test that an Aqara E1 Gateway can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "aqara_e1.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Aqara-Hub-E1-00A0", - model="HE1-G01", - manufacturer="Aqara", - sw_version="3.3.0", - hw_version="1.0", - serial_number="00aa00000a0", - devices=[], - entities=[ - EntityTestInfo( - "alarm_control_panel.aqara_hub_e1_00a0_security_system", - friendly_name="Aqara-Hub-E1-00A0 Security System", - unique_id="00:00:00:00:00:00_1_16", - supported_features=AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY, - state="disarmed", - ), - EntityTestInfo( - "number.aqara_hub_e1_00a0_volume", - friendly_name="Aqara-Hub-E1-00A0 Volume", - unique_id="00:00:00:00:00:00_1_17_1114116", - capabilities={ - "max": 100, - "min": 0, - "mode": NumberMode.AUTO, - "step": 1, - }, - entity_category=EntityCategory.CONFIG, - state="40", - ), - EntityTestInfo( - "switch.aqara_hub_e1_00a0_pairing_mode", - friendly_name="Aqara-Hub-E1-00A0 Pairing Mode", - unique_id="00:00:00:00:00:00_1_17_1114117", - entity_category=EntityCategory.CONFIG, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py deleted file mode 100644 index ae44f7f774fbaa..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Make sure that an Arlo Baby can be setup.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_arlo_baby_setup(hass: HomeAssistant) -> None: - """Test that an Arlo Baby can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "arlo_baby.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="ArloBabyA0", - model="ABC1000", - manufacturer="Netgear, Inc", - sw_version="1.10.931", - hw_version="", - serial_number="00A0000000000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="camera.arlobabya0", - unique_id="00:00:00:00:00:00_1", - friendly_name="ArloBabyA0", - state="idle", - ), - EntityTestInfo( - entity_id="binary_sensor.arlobabya0_motion", - unique_id="00:00:00:00:00:00_1_500", - friendly_name="ArloBabyA0 Motion", - state="off", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_battery", - unique_id="00:00:00:00:00:00_1_700", - friendly_name="ArloBabyA0 Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="82", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_humidity", - unique_id="00:00:00:00:00:00_1_900", - friendly_name="ArloBabyA0 Humidity", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="60.099998", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_temperature", - unique_id="00:00:00:00:00:00_1_1000", - friendly_name="ArloBabyA0 Temperature", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=UnitOfTemperature.CELSIUS, - state="24.0", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_air_quality", - unique_id="00:00:00:00:00:00_1_800_802", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - friendly_name="ArloBabyA0 Air Quality", - state="1", - ), - EntityTestInfo( - entity_id="light.arlobabya0_nightlight", - unique_id="00:00:00:00:00:00_1_1100", - friendly_name="ArloBabyA0 Nightlight", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py b/tests/components/homekit_controller/specific_devices/test_ecobee_501.py deleted file mode 100644 index c833ea711169bd..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Tests for Ecobee 501.""" -from homeassistant.components.climate import ( - SUPPORT_FAN_MODE, - SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) -from homeassistant.const import STATE_ON -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_ecobee501_setup(hass: HomeAssistant) -> None: - """Test that a Ecobee 501 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "ecobee_501.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="My ecobee", - model="ECB501", - manufacturer="ecobee Inc.", - sw_version="4.7.340214", - hw_version="", - serial_number="123456789016", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.my_ecobee", - friendly_name="My ecobee", - unique_id="00:00:00:00:00:00_1_16", - supported_features=( - SUPPORT_TARGET_TEMPERATURE - | SUPPORT_TARGET_TEMPERATURE_RANGE - | SUPPORT_TARGET_HUMIDITY - | SUPPORT_FAN_MODE - ), - capabilities={ - "hvac_modes": ["off", "heat", "cool", "heat_cool"], - "fan_modes": ["on", "auto"], - "min_temp": 7.2, - "max_temp": 33.3, - "min_humidity": 20, - "max_humidity": 50, - }, - state="heat_cool", - ), - EntityTestInfo( - entity_id="binary_sensor.my_ecobee_occupancy", - friendly_name="My ecobee Occupancy", - unique_id="00:00:00:00:00:00_1_57", - unit_of_measurement=None, - state=STATE_ON, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py deleted file mode 100644 index f9d19c5f9c1b0d..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Regression tests for Ecobee occupancy. - -https://github.com/home-assistant/core/issues/31827 -""" -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_ecobee_occupancy_setup(hass: HomeAssistant) -> None: - """Test that an Ecbobee occupancy sensor be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "ecobee_occupancy.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Master Fan", - model="ecobee Switch+", - manufacturer="ecobee Inc.", - sw_version="4.5.130201", - hw_version="", - serial_number="111111111111", - devices=[], - entities=[ - EntityTestInfo( - entity_id="binary_sensor.master_fan", - friendly_name="Master Fan", - unique_id="00:00:00:00:00:00_1_56", - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_eve_degree.py b/tests/components/homekit_controller/specific_devices/test_eve_degree.py deleted file mode 100644 index 10fcd8ede8e573..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_eve_degree.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" -from homeassistant.components.number import NumberMode -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - PERCENTAGE, - EntityCategory, - UnitOfPressure, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_eve_degree_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "eve_degree.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Eve Degree AA11", - model="Eve Degree 00AAA0000", - manufacturer="Elgato", - sw_version="1.2.8", - hw_version="1.0.0", - serial_number="AA00A0A00000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_temperature", - unique_id="00:00:00:00:00:00_1_22", - friendly_name="Eve Degree AA11 Temperature", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=UnitOfTemperature.CELSIUS, - state="22.7719116210938", - ), - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_humidity", - unique_id="00:00:00:00:00:00_1_27", - friendly_name="Eve Degree AA11 Humidity", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="59.4818115234375", - ), - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_air_pressure", - unique_id="00:00:00:00:00:00_1_30_32", - friendly_name="Eve Degree AA11 Air Pressure", - unit_of_measurement=UnitOfPressure.HPA, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="1005.70001220703", - ), - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_battery", - unique_id="00:00:00:00:00:00_1_17", - friendly_name="Eve Degree AA11 Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="65", - ), - EntityTestInfo( - entity_id="number.eve_degree_aa11_elevation", - unique_id="00:00:00:00:00:00_1_30_33", - friendly_name="Eve Degree AA11 Elevation", - capabilities={ - "max": 9000, - "min": -450, - "mode": NumberMode.AUTO, - "step": 1, - }, - state="0", - entity_category=EntityCategory.CONFIG, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_eve_energy.py b/tests/components/homekit_controller/specific_devices/test_eve_energy.py deleted file mode 100644 index 5f8415c5074272..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_eve_energy.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - ELECTRIC_CURRENT_AMPERE, - ELECTRIC_POTENTIAL_VOLT, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, - EntityCategory, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_eve_energy_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "eve_energy.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Eve Energy 50FF", - model="Eve Energy 20EAO8601", - manufacturer="Elgato", - sw_version="1.2.9", - hw_version="1.0.0", - serial_number="AA00A0A00000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.eve_energy_50ff", - unique_id="00:00:00:00:00:00_1_28", - friendly_name="Eve Energy 50FF", - state="off", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_amps", - unique_id="00:00:00:00:00:00_1_28_33", - friendly_name="Eve Energy 50FF Amps", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="0", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_volts", - unique_id="00:00:00:00:00:00_1_28_32", - friendly_name="Eve Energy 50FF Volts", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="0.400000005960464", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_power", - unique_id="00:00:00:00:00:00_1_28_34", - friendly_name="Eve Energy 50FF Power", - unit_of_measurement=POWER_WATT, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="0", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_energy_kwh", - unique_id="00:00:00:00:00:00_1_28_35", - friendly_name="Eve Energy 50FF Energy kWh", - capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state="0.28999999165535", - ), - EntityTestInfo( - entity_id="switch.eve_energy_50ff_lock_physical_controls", - unique_id="00:00:00:00:00:00_1_28_36", - friendly_name="Eve Energy 50FF Lock Physical Controls", - entity_category=EntityCategory.CONFIG, - state="off", - ), - EntityTestInfo( - entity_id="button.eve_energy_50ff_identify", - unique_id="00:00:00:00:00:00_1_1_3", - friendly_name="Eve Energy 50FF Identify", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_haa_fan.py b/tests/components/homekit_controller/specific_devices/test_haa_fan.py deleted file mode 100644 index 07a7324032b381..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_haa_fan.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Make sure that a H.A.A. fan can be setup.""" -from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntityFeature -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_haa_fan_setup(hass: HomeAssistant) -> None: - """Test that a H.A.A. fan can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "haa_fan.json") - await setup_test_accessories(hass, accessories) - - haa_fan_state = hass.states.get("fan.haa_c718b3") - attributes = haa_fan_state.attributes - assert attributes[ATTR_PERCENTAGE] == 66 - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="HAA-C718B3", - model="RavenSystem HAA", - manufacturer="José A. Jiménez Campos", - sw_version="5.0.18", - hw_version="", - serial_number="C718B3-1", - devices=[ - DeviceTestInfo( - name="HAA-C718B3", - model="RavenSystem HAA", - manufacturer="José A. Jiménez Campos", - sw_version="5.0.18", - hw_version="", - serial_number="C718B3-2", - unique_id="00:00:00:00:00:00:aid:2", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.haa_c718b3", - friendly_name="HAA-C718B3", - unique_id="00:00:00:00:00:00_2_8", - state="off", - ) - ], - ), - ], - entities=[ - EntityTestInfo( - entity_id="fan.haa_c718b3", - friendly_name="HAA-C718B3", - unique_id="00:00:00:00:00:00_1_8", - state="on", - supported_features=FanEntityFeature.SET_SPEED, - capabilities={ - "preset_modes": None, - }, - ), - EntityTestInfo( - entity_id="button.haa_c718b3_setup", - friendly_name="HAA-C718B3 Setup", - unique_id="00:00:00:00:00:00_1_1010_1012", - entity_category=EntityCategory.CONFIG, - state="unknown", - ), - EntityTestInfo( - entity_id="button.haa_c718b3_update", - friendly_name="HAA-C718B3 Update", - unique_id="00:00:00:00:00:00_1_1010_1011", - entity_category=EntityCategory.CONFIG, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py deleted file mode 100644 index 84a14a8488da13..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Test against characteristics captured from the Home Assistant HomeKit bridge running demo platforms.""" -from homeassistant.components.fan import FanEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_homeassistant_bridge_fan_setup(hass: HomeAssistant) -> None: - """Test that a SIMPLEconnect fan can be correctly setup in HA.""" - accessories = await setup_accessories_from_file( - hass, "home_assistant_bridge_fan.json" - ) - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Home Assistant Bridge", - model="Bridge", - manufacturer="Home Assistant", - sw_version="0.104.0.dev0", - hw_version="", - serial_number="homekit.bridge", - devices=[ - DeviceTestInfo( - name="Living Room Fan", - model="Fan", - manufacturer="Home Assistant", - sw_version="0.104.0.dev0", - hw_version="", - serial_number="fan.living_room_fan", - unique_id="00:00:00:00:00:00:aid:1256851357", - devices=[], - entities=[ - EntityTestInfo( - entity_id="fan.living_room_fan", - friendly_name="Living Room Fan", - unique_id="00:00:00:00:00:00_1256851357_8", - supported_features=( - FanEntityFeature.DIRECTION - | FanEntityFeature.SET_SPEED - | FanEntityFeature.OSCILLATE - ), - capabilities={ - "preset_modes": None, - }, - state="off", - ) - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py b/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py deleted file mode 100644 index 5bb7003e58b852..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Tests for handling accessories on a Homespan esp32 daikin bridge.""" -from homeassistant.components.climate import ClimateEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_homespan_daikin_bridge_setup(hass: HomeAssistant) -> None: - """Test that aHomespan esp32 daikin bridge can be correctly setup in HA via HomeKit.""" - accessories = await setup_accessories_from_file(hass, "homespan_daikin_bridge.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Air Conditioner", - model="Daikin-fwec3a-esp32-homekit-bridge", - manufacturer="Garzola Marco", - sw_version="1.0.0", - hw_version="1.0.0", - serial_number="00000001", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.air_conditioner_slaveid_1", - friendly_name="Air Conditioner SlaveID 1", - unique_id="00:00:00:00:00:00_1_9", - supported_features=( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - ), - capabilities={ - "hvac_modes": ["heat_cool", "heat", "cool", "off"], - "min_temp": 18, - "max_temp": 32, - "target_temp_step": 0.5, - "fan_modes": ["off", "low", "medium", "high"], - }, - state="cool", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index ca2392be4ce394..e25d5b7830e294 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -7,62 +7,16 @@ from aiohomekit.testing import FakePairing import pytest -from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - Helper, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) +from ..common import Helper, setup_accessories_from_file, setup_test_accessories from tests.common import async_fire_time_changed LIGHT_ON = ("lightbulb", "on") -async def test_koogeek_ls1_setup(hass: HomeAssistant) -> None: - """Test that a Koogeek LS1 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Koogeek-LS1-20833F", - model="LS1", - manufacturer="Koogeek", - sw_version="2.2.15", - hw_version="", - serial_number="AAAA011111111111", - devices=[], - entities=[ - EntityTestInfo( - entity_id="light.koogeek_ls1_20833f_light_strip", - friendly_name="Koogeek-LS1-20833F Light Strip", - unique_id="00:00:00:00:00:00_1_7", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="off", - ), - EntityTestInfo( - entity_id="button.koogeek_ls1_20833f_identify", - friendly_name="Koogeek-LS1-20833F Identify", - unique_id="00:00:00:00:00:00_1_1_6", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - ], - ), - ) - - @pytest.mark.parametrize("failure_cls", [AccessoryDisconnectedError, EncryptionError]) async def test_recover_from_failure(hass: HomeAssistant, utcnow, failure_cls) -> None: """Test that entity actually recovers from a network connection drop. diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py deleted file mode 100644 index 91506382a8a0f9..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Make sure that existing Koogeek P1EU support isn't broken.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import POWER_WATT -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_koogeek_p1eu_setup(hass: HomeAssistant) -> None: - """Test that a Koogeek P1EU can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "koogeek_p1eu.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Koogeek-P1-A00AA0", - model="P1EU", - manufacturer="Koogeek", - sw_version="2.3.7", - hw_version="", - serial_number="EUCP03190xxxxx48", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.koogeek_p1_a00aa0_outlet", - friendly_name="Koogeek-P1-A00AA0 outlet", - unique_id="00:00:00:00:00:00_1_7", - state="off", - ), - EntityTestInfo( - entity_id="sensor.koogeek_p1_a00aa0_power", - friendly_name="Koogeek-P1-A00AA0 Power", - unique_id="00:00:00:00:00:00_1_21_22", - unit_of_measurement=POWER_WATT, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="5", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py deleted file mode 100644 index 4578014f009a42..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Regression tests for Aqara Gateway V3. - -https://github.com/home-assistant/core/issues/20885 -""" -from homeassistant.components.climate import ( - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_lennox_e30_setup(hass: HomeAssistant) -> None: - """Test that a Lennox E30 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "lennox_e30.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Lennox", - model="E30 2B", - manufacturer="Lennox", - sw_version="3.40.XX", - hw_version="3.0.XX", - serial_number="XXXXXXXX", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.lennox", - friendly_name="Lennox", - unique_id="00:00:00:00:00:00_1_100", - supported_features=( - SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE - ), - capabilities={ - "hvac_modes": ["off", "heat", "cool", "heat_cool"], - "max_temp": 37, - "min_temp": 4.5, - }, - state="heat_cool", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py deleted file mode 100644 index f35e7da2bdd2bf..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Make sure that handling real world LG HomeKit characteristics isn't broken.""" -from homeassistant.components.media_player import MediaPlayerEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_lg_tv(hass: HomeAssistant) -> None: - """Test that a Koogeek LS1 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "lg_tv.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="LG webOS TV AF80", - model="OLED55B9PUA", - manufacturer="LG Electronics", - sw_version="04.71.04", - hw_version="1", - serial_number="999AAAAAA999", - devices=[], - entities=[ - EntityTestInfo( - entity_id="media_player.lg_webos_tv_af80", - friendly_name="LG webOS TV AF80", - unique_id="00:00:00:00:00:00_1_48", - supported_features=( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.SELECT_SOURCE - ), - capabilities={ - "source_list": [ - "AirPlay", - "Live TV", - "HDMI 1", - "Sony", - "Apple", - "AV", - "HDMI 4", - ] - }, - # The LG TV doesn't (at least at this patch level) report - # its media state via CURRENT_MEDIA_STATE. Therefore "ok" - # is the best we can say. - state="on", - ), - ], - ), - ) - - """ - assert state.attributes["source"] == "HDMI 4" - """ diff --git a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py b/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py deleted file mode 100644 index 9cb65907e8a566..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Tests for handling accessories on a Lutron Caseta bridge via HomeKit.""" -from homeassistant.const import STATE_OFF -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_lutron_caseta_bridge_setup(hass: HomeAssistant) -> None: - """Test that a Lutron Caseta bridge can be correctly setup in HA via HomeKit.""" - accessories = await setup_accessories_from_file(hass, "lutron_caseta_bridge.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Smart Bridge 2", - model="L-BDG2-WH", - manufacturer="Lutron Electronics Co., Inc", - sw_version="08.08", - hw_version="", - serial_number="12344331", - devices=[ - DeviceTestInfo( - name="Cas\u00e9ta\u00ae Wireless Fan Speed Control", - model="PD-FSQN-XX", - manufacturer="Lutron Electronics Co., Inc", - sw_version="001.005", - hw_version="", - serial_number="39024290", - unique_id="00:00:00:00:00:00:aid:21474836482", - devices=[], - entities=[ - EntityTestInfo( - entity_id="fan.caseta_r_wireless_fan_speed_control", - friendly_name="Caséta® Wireless Fan Speed Control", - unique_id="00:00:00:00:00:00_21474836482_2", - unit_of_measurement=None, - supported_features=1, - state=STATE_OFF, - capabilities={"preset_modes": None}, - ) - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_mss425f.py b/tests/components/homekit_controller/specific_devices/test_mss425f.py deleted file mode 100644 index 1ab608e3d2e0d0..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_mss425f.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Tests for the Meross MSS425f power strip.""" -from homeassistant.const import STATE_ON, STATE_UNKNOWN, EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_meross_mss425f_setup(hass: HomeAssistant) -> None: - """Test that a MSS425f can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "mss425f.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="MSS425F-15cc", - model="MSS425F", - manufacturer="Meross", - sw_version="4.2.3", - hw_version="4.0.0", - serial_number="HH41234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="button.mss425f_15cc_identify", - friendly_name="MSS425F-15cc Identify", - unique_id="00:00:00:00:00:00_1_1_2", - entity_category=EntityCategory.DIAGNOSTIC, - state=STATE_UNKNOWN, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_1", - friendly_name="MSS425F-15cc Outlet-1", - unique_id="00:00:00:00:00:00_1_12", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_2", - friendly_name="MSS425F-15cc Outlet-2", - unique_id="00:00:00:00:00:00_1_15", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_3", - friendly_name="MSS425F-15cc Outlet-3", - unique_id="00:00:00:00:00:00_1_18", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_4", - friendly_name="MSS425F-15cc Outlet-4", - unique_id="00:00:00:00:00:00_1_21", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_usb", - friendly_name="MSS425F-15cc USB", - unique_id="00:00:00:00:00:00_1_24", - state=STATE_ON, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_mss565.py b/tests/components/homekit_controller/specific_devices/test_mss565.py deleted file mode 100644 index 78d8d8f5250131..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_mss565.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Tests for the Meross MSS565 wall switch.""" -from homeassistant.const import STATE_ON -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_meross_mss565_setup(hass: HomeAssistant) -> None: - """Test that a MSS565 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "mss565.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="MSS565-28da", - model="MSS565", - manufacturer="Meross", - sw_version="4.1.9", - hw_version="4.0.0", - serial_number="BB1121", - devices=[], - entities=[ - EntityTestInfo( - entity_id="light.mss565_28da_dimmer_switch", - friendly_name="MSS565-28da Dimmer Switch", - unique_id="00:00:00:00:00:00_1_12", - capabilities={"supported_color_modes": ["brightness"]}, - state=STATE_ON, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_mysa_living.py b/tests/components/homekit_controller/specific_devices/test_mysa_living.py deleted file mode 100644 index 48828a2a6ad6b1..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_mysa_living.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Make sure that Mysa Living is enumerated properly.""" -from homeassistant.components.climate import ClimateEntityFeature -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, UnitOfTemperature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_mysa_living_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "mysa_living.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Mysa-85dda9", - model="v1", - manufacturer="Empowered Homes Inc.", - sw_version="2.8.1", - hw_version="", - serial_number="AAAAAAA000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.mysa_85dda9_thermostat", - friendly_name="Mysa-85dda9 Thermostat", - unique_id="00:00:00:00:00:00_1_20", - supported_features=ClimateEntityFeature.TARGET_TEMPERATURE, - capabilities={ - "hvac_modes": ["off", "heat", "cool", "heat_cool"], - "max_temp": 35, - "min_temp": 7, - }, - state="off", - ), - EntityTestInfo( - entity_id="sensor.mysa_85dda9_current_humidity", - friendly_name="Mysa-85dda9 Current Humidity", - unique_id="00:00:00:00:00:00_1_20_27", - unit_of_measurement=PERCENTAGE, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="40", - ), - EntityTestInfo( - entity_id="sensor.mysa_85dda9_current_temperature", - friendly_name="Mysa-85dda9 Current Temperature", - unique_id="00:00:00:00:00:00_1_20_25", - unit_of_measurement=UnitOfTemperature.CELSIUS, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="24.1", - ), - EntityTestInfo( - entity_id="light.mysa_85dda9_display", - friendly_name="Mysa-85dda9 Display", - unique_id="00:00:00:00:00:00_1_40", - supported_features=0, - capabilities={"supported_color_modes": ["brightness"]}, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py b/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py deleted file mode 100644 index 629059935cfabf..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Make sure that Nanoleaf NL55 works with BLE.""" -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - -LIGHT_ON = ("lightbulb", "on") - - -async def test_nanoleaf_nl55_setup(hass: HomeAssistant) -> None: - """Test that a Nanoleaf NL55 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "nanoleaf_strip_nl55.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Nanoleaf Strip 3B32", - model="NL55", - manufacturer="Nanoleaf", - sw_version="1.4.40", - hw_version="1.2.4", - serial_number="AAAA011111111111", - devices=[], - entities=[ - EntityTestInfo( - entity_id="light.nanoleaf_strip_3b32_nanoleaf_light_strip", - friendly_name="Nanoleaf Strip 3B32 Nanoleaf Light Strip", - unique_id="00:00:00:00:00:00_1_19", - supported_features=0, - capabilities={ - "max_color_temp_kelvin": 6535, - "min_color_temp_kelvin": 2127, - "max_mireds": 470, - "min_mireds": 153, - "supported_color_modes": ["color_temp", "hs"], - }, - state="on", - ), - EntityTestInfo( - entity_id="button.nanoleaf_strip_3b32_identify", - friendly_name="Nanoleaf Strip 3B32 Identify", - unique_id="00:00:00:00:00:00_1_1_2", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - EntityTestInfo( - entity_id="sensor.nanoleaf_strip_3b32_thread_capabilities", - friendly_name="Nanoleaf Strip 3B32 Thread Capabilities", - unique_id="00:00:00:00:00:00_1_31_115", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router_capable", - "full", - "minimal", - "none", - "router_eligible", - "sleepy", - ] - }, - state="border_router_capable", - ), - EntityTestInfo( - entity_id="sensor.nanoleaf_strip_3b32_thread_status", - friendly_name="Nanoleaf Strip 3B32 Thread Status", - unique_id="00:00:00:00:00:00_1_31_117", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router", - "child", - "detached", - "disabled", - "joining", - "leader", - "router", - ] - }, - state="border_router", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py b/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py deleted file mode 100644 index 71807871cc1dd5..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Regression tests for Netamo Smart CO Alarm. - -https://github.com/home-assistant/core/issues/78903 -""" -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_netamo_smart_co_alarm_setup(hass: HomeAssistant) -> None: - """Test that a Netamo Smart CO Alarm can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "netamo_smart_co_alarm.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Smart CO Alarm", - model="Smart CO Alarm", - manufacturer="Netatmo", - sw_version="1.0.3", - hw_version="0", - serial_number="1234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="binary_sensor.smart_co_alarm_carbon_monoxide_sensor", - friendly_name="Smart CO Alarm Carbon Monoxide Sensor", - unique_id="00:00:00:00:00:00_1_22", - state="off", - ), - EntityTestInfo( - entity_id="binary_sensor.smart_co_alarm_low_battery", - friendly_name="Smart CO Alarm Low Battery", - entity_category=EntityCategory.DIAGNOSTIC, - unique_id="00:00:00:00:00:00_1_36", - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py b/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py deleted file mode 100644 index e9e6749bd36777..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Regression tests for Netamo Healthy Home Coach. - -https://github.com/home-assistant/core/issues/73360 -""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_netamo_smart_co_alarm_setup(hass: HomeAssistant) -> None: - """Test that a Netamo Smart CO Alarm can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "netatmo_home_coach.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Healthy Home Coach", - model="Healthy Home Coach", - manufacturer="Netatmo", - sw_version="59", - hw_version="", - serial_number="1234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="sensor.healthy_home_coach_noise", - friendly_name="Healthy Home Coach Noise", - unique_id="00:00:00:00:00:00_1_20_21", - state="0", - unit_of_measurement="dB", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py deleted file mode 100644 index 24a4dbe034919d..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Make sure that existing RainMachine support isn't broken. - -https://github.com/home-assistant/core/issues/31745 -""" -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_rainmachine_pro_8_setup(hass: HomeAssistant) -> None: - """Test that a RainMachine can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "rainmachine-pro-8.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="RainMachine-00ce4a", - model="SPK5 Pro", - manufacturer="Green Electronics LLC", - sw_version="1.0.4", - hw_version="1", - serial_number="00aa0000aa0a", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_512", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_2", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_768", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_3", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1024", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_4", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1280", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_5", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1536", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_6", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1792", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_7", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_2048", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_8", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_2304", - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py b/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py deleted file mode 100644 index d56aa4ad481380..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Test against characteristics captured from a ryse smart bridge platforms.""" -from homeassistant.components.cover import CoverEntityFeature -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - -RYSE_SUPPORTED_FEATURES = ( - CoverEntityFeature.CLOSE | CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN -) - - -async def test_ryse_smart_bridge_setup(hass: HomeAssistant) -> None: - """Test that a Ryse smart bridge can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "ryse_smart_bridge.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="RYSE SmartBridge", - model="RYSE SmartBridge", - manufacturer="RYSE Inc.", - sw_version="1.3.0", - hw_version="0101.3521.0436", - devices=[ - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:2", - name="Master Bath South", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.master_bath_south_ryse_shade", - friendly_name="Master Bath South RYSE Shade", - unique_id="00:00:00:00:00:00_2_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="closed", - ), - EntityTestInfo( - entity_id="sensor.master_bath_south_ryse_shade_battery", - friendly_name="Master Bath South RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:3", - name="RYSE SmartShade", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="", - hw_version="", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.ryse_smartshade_ryse_shade", - friendly_name="RYSE SmartShade RYSE Shade", - unique_id="00:00:00:00:00:00_3_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="open", - ), - EntityTestInfo( - entity_id="sensor.ryse_smartshade_ryse_shade_battery", - friendly_name="RYSE SmartShade RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_3_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - ], - entities=[], - ), - ) - - -async def test_ryse_smart_bridge_four_shades_setup(hass: HomeAssistant) -> None: - """Test that a Ryse smart bridge with four shades can be correctly setup in HA.""" - accessories = await setup_accessories_from_file( - hass, "ryse_smart_bridge_four_shades.json" - ) - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="RYSE SmartBridge", - model="RYSE SmartBridge", - manufacturer="RYSE Inc.", - sw_version="1.3.0", - hw_version="0401.3521.0679", - devices=[ - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:2", - name="LR Left", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.lr_left_ryse_shade", - friendly_name="LR Left RYSE Shade", - unique_id="00:00:00:00:00:00_2_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="closed", - ), - EntityTestInfo( - entity_id="sensor.lr_left_ryse_shade_battery", - friendly_name="LR Left RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_64", - unit_of_measurement=PERCENTAGE, - state="89", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:3", - name="LR Right", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.lr_right_ryse_shade", - friendly_name="LR Right RYSE Shade", - unique_id="00:00:00:00:00:00_3_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="closed", - ), - EntityTestInfo( - entity_id="sensor.lr_right_ryse_shade_battery", - friendly_name="LR Right RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_3_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:4", - name="BR Left", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.br_left_ryse_shade", - friendly_name="BR Left RYSE Shade", - unique_id="00:00:00:00:00:00_4_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="open", - ), - EntityTestInfo( - entity_id="sensor.br_left_ryse_shade_battery", - friendly_name="BR Left RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_4_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:5", - name="RZSS", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.rzss_ryse_shade", - friendly_name="RZSS RYSE Shade", - unique_id="00:00:00:00:00:00_5_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="open", - ), - EntityTestInfo( - entity_id="sensor.rzss_ryse_shade_battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - friendly_name="RZSS RYSE Shade Battery", - unique_id="00:00:00:00:00:00_5_64", - unit_of_measurement=PERCENTAGE, - state="0", - ), - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py b/tests/components/homekit_controller/specific_devices/test_schlage_sense.py deleted file mode 100644 index 6ed0a97c23d2f7..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Make sure that Schlage Sense is enumerated properly.""" -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_schlage_sense_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "schlage_sense.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="SENSE ", - model="BE479CAM619", - manufacturer="Schlage ", - sw_version="004.027.000", - hw_version="1.3.0", - serial_number="AAAAAAA000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="lock.sense_lock_mechanism", - friendly_name="SENSE Lock Mechanism", - unique_id="00:00:00:00:00:00_1_30", - supported_features=0, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py deleted file mode 100644 index 59e7d2855e448e..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Test against characteristics captured from a SIMPLEconnect Fan. - -https://github.com/home-assistant/core/issues/26180 -""" -from homeassistant.components.fan import FanEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_simpleconnect_fan_setup(hass: HomeAssistant) -> None: - """Test that a SIMPLEconnect fan can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "simpleconnect_fan.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="SIMPLEconnect Fan-06F674", - model="SIMPLEconnect", - manufacturer="Hunter Fan", - sw_version="", - hw_version="", - serial_number="1234567890abcd", - devices=[], - entities=[ - EntityTestInfo( - entity_id="fan.simpleconnect_fan_06f674_hunter_fan", - friendly_name="SIMPLEconnect Fan-06F674 Hunter Fan", - unique_id="00:00:00:00:00:00_1_8", - supported_features=FanEntityFeature.DIRECTION - | FanEntityFeature.SET_SPEED, - capabilities={ - "preset_modes": None, - }, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py deleted file mode 100644 index 854de4b89d8361..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Test against characteristics captured from a Velux Gateway. - -https://github.com/home-assistant/core/issues/44314 -""" -from homeassistant.components.cover import CoverEntityFeature -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - CONCENTRATION_PARTS_PER_MILLION, - PERCENTAGE, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_velux_cover_setup(hass: HomeAssistant) -> None: - """Test that a velux gateway can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "velux_gateway.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="VELUX Gateway", - model="VELUX Gateway", - manufacturer="VELUX", - sw_version="70", - hw_version="", - serial_number="a1a11a1", - devices=[ - DeviceTestInfo( - name="VELUX Window", - model="VELUX Window", - manufacturer="VELUX", - sw_version="48", - hw_version="", - serial_number="1111111a114a111a", - unique_id="00:00:00:00:00:00:aid:3", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.velux_window_roof_window", - friendly_name="VELUX Window Roof Window", - unique_id="00:00:00:00:00:00_3_8", - supported_features=CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - | CoverEntityFeature.OPEN, - state="closed", - ), - ], - ), - DeviceTestInfo( - name="VELUX Sensor", - model="VELUX Sensor", - manufacturer="VELUX", - sw_version="16", - hw_version="", - serial_number="a11b111", - unique_id="00:00:00:00:00:00:aid:2", - devices=[], - entities=[ - EntityTestInfo( - entity_id="sensor.velux_sensor_temperature_sensor", - friendly_name="VELUX Sensor Temperature sensor", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_8", - unit_of_measurement=UnitOfTemperature.CELSIUS, - state="18.9", - ), - EntityTestInfo( - entity_id="sensor.velux_sensor_humidity_sensor", - friendly_name="VELUX Sensor Humidity sensor", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_11", - unit_of_measurement=PERCENTAGE, - state="58", - ), - EntityTestInfo( - entity_id="sensor.velux_sensor_carbon_dioxide_sensor", - friendly_name="VELUX Sensor Carbon Dioxide sensor", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_14", - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - state="400", - ), - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py deleted file mode 100644 index fed8f05b0b9ed1..00000000000000 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Make sure that Vocolinc Flowerbud is enumerated properly.""" -from homeassistant.components.humidifier import HumidifierEntityFeature -from homeassistant.components.number import NumberMode -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_vocolinc_flowerbud_setup(hass: HomeAssistant) -> None: - """Test that a Vocolinc Flowerbud can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "vocolinc_flowerbud.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="VOCOlinc-Flowerbud-0d324b", - model="Flowerbud", - manufacturer="VOCOlinc", - sw_version="3.121.2", - hw_version="0.1", - serial_number="AM01121849000327", - devices=[], - entities=[ - EntityTestInfo( - entity_id="humidifier.vocolinc_flowerbud_0d324b", - friendly_name="VOCOlinc-Flowerbud-0d324b", - unique_id="00:00:00:00:00:00_1_30", - supported_features=HumidifierEntityFeature.MODES, - capabilities={ - "available_modes": ["normal", "auto"], - "max_humidity": 100.0, - "min_humidity": 0.0, - }, - state="off", - ), - EntityTestInfo( - entity_id="light.vocolinc_flowerbud_0d324b_mood_light", - friendly_name="VOCOlinc-Flowerbud-0d324b Mood Light", - unique_id="00:00:00:00:00:00_1_9", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="on", - ), - EntityTestInfo( - entity_id="number.vocolinc_flowerbud_0d324b_spray_quantity", - friendly_name="VOCOlinc-Flowerbud-0d324b Spray Quantity", - unique_id="00:00:00:00:00:00_1_30_38", - capabilities={ - "max": 5, - "min": 1, - "mode": NumberMode.AUTO, - "step": 1, - }, - state="5", - entity_category=EntityCategory.CONFIG, - ), - EntityTestInfo( - entity_id="sensor.vocolinc_flowerbud_0d324b_current_humidity", - friendly_name="VOCOlinc-Flowerbud-0d324b Current Humidity", - unique_id="00:00:00:00:00:00_1_30_33", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="45.0", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 8ffeec093f6bfd..23c6e245ac7bd3 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -1,5 +1,6 @@ """Tests for homekit_controller init.""" from datetime import timedelta +import pathlib from unittest.mock import patch from aiohomekit import AccessoryNotFoundError @@ -7,6 +8,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes from aiohomekit.testing import FakePairing +from attr import asdict +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.homekit_controller.const import DOMAIN, ENTITY_MAP from homeassistant.config_entries import ConfigEntryState @@ -20,6 +24,8 @@ from .common import ( Helper, remove_device, + setup_accessories_from_file, + setup_test_accessories, setup_test_accessories_with_controller, setup_test_component, ) @@ -27,6 +33,9 @@ from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator +FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" +FIXTURES = [path.relative_to(FIXTURES_DIR) for path in FIXTURES_DIR.glob("*.json")] + ALIVE_DEVICE_NAME = "testdevice" ALIVE_DEVICE_ENTITY_ID = "light.testdevice" @@ -218,3 +227,57 @@ async def get_characteristics(self, chars, *args, **kwargs): is_available = True async_fire_time_changed(hass, utcnow() + timedelta(hours=1)) assert hass.states.get("light.testdevice").state == STATE_OFF + + +@pytest.mark.parametrize("example", FIXTURES, ids=lambda val: str(val.stem)) +async def test_snapshots( + hass: HomeAssistant, snapshot: SnapshotAssertion, example: str +) -> None: + """Detect regressions in enumerating a homekit accessory database and building entities.""" + accessories = await setup_accessories_from_file(hass, example) + config_entry, _ = await setup_test_accessories(hass, accessories) + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + registry_devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + registry_devices.sort(key=lambda device: device.name) + + devices = [] + + for device in registry_devices: + entities = [] + + registry_entities = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + registry_entities.sort(key=lambda entity: entity.entity_id) + + for entity_entry in registry_entities: + state_dict = None + if state := hass.states.get(entity_entry.entity_id): + state_dict = dict(state.as_dict()) + state_dict.pop("context", None) + state_dict.pop("last_changed", None) + state_dict.pop("last_updated", None) + + state_dict["attributes"] = dict(state_dict["attributes"]) + state_dict["attributes"].pop("access_token", None) + state_dict["attributes"].pop("entity_picture", None) + + entry = asdict(entity_entry) + entry.pop("id", None) + entry.pop("device_id", None) + + entities.append({"entry": entry, "state": state_dict}) + + device_dict = asdict(device) + device_dict.pop("id", None) + device_dict.pop("via_device_id", None) + devices.append({"device": device_dict, "entities": entities}) + + assert snapshot == devices diff --git a/tests/components/homekit_controller/test_select.py b/tests/components/homekit_controller/test_select.py index d9feebafc762bb..9cfa0bccda3aea 100644 --- a/tests/components/homekit_controller/test_select.py +++ b/tests/components/homekit_controller/test_select.py @@ -1,6 +1,7 @@ """Basic checks for HomeKit select entities.""" from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics.const import TemperatureDisplayUnits from aiohomekit.model.services import ServicesTypes from homeassistant.core import HomeAssistant @@ -22,6 +23,16 @@ def create_service_with_ecobee_mode(accessory: Accessory): return service +def create_service_with_temperature_units(accessory: Accessory): + """Define a thermostat with ecobee mode characteristics.""" + service = accessory.add_service(ServicesTypes.TEMPERATURE_SENSOR, add_required=True) + + units = service.add_char(CharacteristicsTypes.TEMPERATURE_UNITS) + units.value = 0 + + return service + + async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: """Test we can migrate a select unique id.""" entity_registry = er.async_get(hass) @@ -125,3 +136,76 @@ async def test_write_current_mode(hass: HomeAssistant, utcnow) -> None: ServicesTypes.THERMOSTAT, {CharacteristicsTypes.VENDOR_ECOBEE_SET_HOLD_SCHEDULE: 2}, ) + + +async def test_read_select(hass: HomeAssistant, utcnow) -> None: + """Test the generic select can read the current value.""" + helper = await setup_test_component(hass, create_service_with_temperature_units) + + # Helper will be for the primary entity, which is the service. Make a helper for the sensor. + select_entity = Helper( + hass, + "select.testdevice_temperature_display_units", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + state = await select_entity.async_update( + ServicesTypes.TEMPERATURE_SENSOR, + { + CharacteristicsTypes.TEMPERATURE_UNITS: 0, + }, + ) + assert state.state == "celsius" + + state = await select_entity.async_update( + ServicesTypes.TEMPERATURE_SENSOR, + { + CharacteristicsTypes.TEMPERATURE_UNITS: 1, + }, + ) + assert state.state == "fahrenheit" + + +async def test_write_select(hass: HomeAssistant, utcnow) -> None: + """Test can set a value.""" + helper = await setup_test_component(hass, create_service_with_temperature_units) + helper.accessory.services.first(service_type=ServicesTypes.THERMOSTAT) + + # Helper will be for the primary entity, which is the service. Make a helper for the sensor. + current_mode = Helper( + hass, + "select.testdevice_temperature_display_units", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.testdevice_temperature_display_units", + "option": "fahrenheit", + }, + blocking=True, + ) + current_mode.async_assert_service_values( + ServicesTypes.TEMPERATURE_SENSOR, + {CharacteristicsTypes.TEMPERATURE_UNITS: TemperatureDisplayUnits.FAHRENHEIT}, + ) + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.testdevice_temperature_display_units", + "option": "celsius", + }, + blocking=True, + ) + current_mode.async_assert_service_values( + ServicesTypes.TEMPERATURE_SENSOR, + {CharacteristicsTypes.TEMPERATURE_UNITS: TemperatureDisplayUnits.CELSIUS}, + ) diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 371975e12a506b..24f433f539cad3 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2221,5 +2221,113 @@ "id": "52612630-841e-4d39-9763-60346a0da759", "is_configured": true, "type": "geolocation" + }, + { + "id": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "product_data": { + "model_id": "SOC001", + "manufacturer_name": "Signify Netherlands B.V.", + "product_name": "Hue secure contact sensor", + "product_archetype": "unknown_archetype", + "certified": true, + "software_version": "2.67.9", + "hardware_platform_type": "100b-125" + }, + "metadata": { + "name": "Test contact sensor", + "archetype": "unknown_archetype" + }, + "identify": {}, + "services": [ + { + "rid": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "rtype": "contact" + }, + { + "rid": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "rtype": "tamper" + } + ], + "type": "device" + }, + { + "id": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "owner": { + "rid": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "rtype": "device" + }, + "enabled": true, + "contact_report": { + "changed": "2023-09-27T10:01:36.968Z", + "state": "contact" + }, + "type": "contact" + }, + { + "id": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "owner": { + "rid": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "rtype": "device" + }, + "tamper_reports": [ + { + "changed": "2023-09-25T10:02:08.774Z", + "source": "battery_door", + "state": "not_tampered" + } + ], + "type": "tamper" + }, + { + "id": "1cbda90c-b675-46b0-9e97-278e7e7857ed", + "id_v1": "/sensors/249", + "product_data": { + "model_id": "CAMERA", + "manufacturer_name": "Signify Netherlands B.V.", + "product_name": "Fake Hue Test Camera", + "product_archetype": "unknown_archetype", + "certified": true, + "software_version": "0.0.0", + "hardware_platform_type": "0" + }, + "metadata": { + "name": "Test Camera", + "archetype": "unknown_archetype" + }, + "identify": {}, + "usertest": { + "status": "set", + "usertest": false + }, + "services": [ + { + "rid": "d9f2cfee-5879-426b-aa1f-553af8f38176", + "rtype": "camera_motion" + } + ], + "type": "device" + }, + { + "id": "d9f2cfee-5879-426b-aa1f-553af8f38176", + "id_v1": "/sensors/249", + "owner": { + "rid": "1cbda90c-b675-46b0-9e97-278e7e7857ed", + "rtype": "device" + }, + "enabled": true, + "motion": { + "motion": true, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-27T10:06:41.822Z", + "motion": true + } + }, + "sensitivity": { + "status": "set", + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "motion" } ] diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index 7750f4a6795b31..3846f17aa763df 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -14,8 +14,8 @@ async def test_binary_sensors( await setup_platform(hass, mock_bridge_v2, "binary_sensor") # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 2 binary_sensors should be created from test data - assert len(hass.states.async_all()) == 2 + # 5 binary_sensors should be created from test data + assert len(hass.states.async_all()) == 5 # test motion sensor sensor = hass.states.get("binary_sensor.hue_motion_sensor_motion") @@ -23,7 +23,6 @@ async def test_binary_sensors( assert sensor.state == "off" assert sensor.name == "Hue motion sensor Motion" assert sensor.attributes["device_class"] == "motion" - assert sensor.attributes["motion_valid"] is True # test entertainment room active sensor sensor = hass.states.get( @@ -34,6 +33,51 @@ async def test_binary_sensors( assert sensor.name == "Entertainmentroom 1: Entertainment Configuration" assert sensor.attributes["device_class"] == "running" + # test contact sensor + sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Test contact sensor Contact" + assert sensor.attributes["device_class"] == "opening" + # test contact sensor disabled == state unknown + mock_bridge_v2.api.emit_event( + "update", + { + "enabled": False, + "id": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "type": "contact", + }, + ) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + assert sensor.state == "unknown" + + # test tamper sensor + sensor = hass.states.get("binary_sensor.test_contact_sensor_tamper") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Test contact sensor Tamper" + assert sensor.attributes["device_class"] == "tamper" + # test tamper sensor when no tamper reports exist + mock_bridge_v2.api.emit_event( + "update", + { + "id": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "tamper_reports": [], + "type": "tamper", + }, + ) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.test_contact_sensor_tamper") + assert sensor.state == "off" + + # test camera_motion sensor + sensor = hass.states.get("binary_sensor.test_camera_motion") + assert sensor is not None + assert sensor.state == "on" + assert sensor.name == "Test Camera Motion" + assert sensor.attributes["device_class"] == "motion" + async def test_binary_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None: """Test if binary_sensor get added/updated from events.""" diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 91eccc2c984159..45e39e94119ee7 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -28,7 +28,6 @@ async def test_sensors( assert sensor.attributes["device_class"] == "temperature" assert sensor.attributes["state_class"] == "measurement" assert sensor.attributes["unit_of_measurement"] == "°C" - assert sensor.attributes["temperature_valid"] is True # test illuminance sensor sensor = hass.states.get("sensor.hue_motion_sensor_illuminance") @@ -39,7 +38,6 @@ async def test_sensors( assert sensor.attributes["state_class"] == "measurement" assert sensor.attributes["unit_of_measurement"] == "lx" assert sensor.attributes["light_level"] == 18027 - assert sensor.attributes["light_level_valid"] is True # test battery sensor sensor = hass.states.get("sensor.wall_switch_with_2_controls_battery") diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index c8fa417b12c037..a576b88a7c3f68 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -14,8 +14,8 @@ async def test_switch( await setup_platform(hass, mock_bridge_v2, "switch") # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 2 entities should be created from test data - assert len(hass.states.async_all()) == 2 + # 3 entities should be created from test data + assert len(hass.states.async_all()) == 3 # test config switch to enable/disable motion sensor test_entity = hass.states.get("switch.hue_motion_sensor_motion") diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index b6e22ec7b80280..309890181526e4 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -1,10 +1,17 @@ """Common fixtures for the Hydrawise tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from typing import Any +from unittest.mock import AsyncMock, Mock, patch import pytest +from homeassistant.components.hydrawise.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -13,3 +20,85 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.hydrawise.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_pydrawise( + mock_controller: dict[str, Any], + mock_zones: list[dict[str, Any]], +) -> Generator[Mock, None, None]: + """Mock LegacyHydrawise.""" + with patch("pydrawise.legacy.LegacyHydrawise", autospec=True) as mock_pydrawise: + mock_pydrawise.return_value.controller_info = {"controllers": [mock_controller]} + mock_pydrawise.return_value.current_controller = mock_controller + mock_pydrawise.return_value.controller_status = {"relays": mock_zones} + mock_pydrawise.return_value.relays = mock_zones + yield mock_pydrawise.return_value + + +@pytest.fixture +def mock_controller() -> dict[str, Any]: + """Mock Hydrawise controller.""" + return { + "name": "Home Controller", + "last_contact": 1693292420, + "serial_number": "0310b36090", + "controller_id": 52496, + "status": "Unknown", + } + + +@pytest.fixture +def mock_zones() -> list[dict[str, Any]]: + """Mock Hydrawise zones.""" + return [ + { + "name": "Zone One", + "period": 259200, + "relay": 1, + "relay_id": 5965394, + "run": 1800, + "stop": 1, + "time": 330597, + "timestr": "Sat", + "type": 1, + }, + { + "name": "Zone Two", + "period": 259200, + "relay": 2, + "relay_id": 5965395, + "run": 1788, + "stop": 1, + "time": 1, + "timestr": "Now", + "type": 106, + }, + ] + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="Hydrawise", + domain=DOMAIN, + data={ + CONF_API_KEY: "abc123", + }, + unique_id="hydrawise-customerid", + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pydrawise: Mock, +) -> MockConfigEntry: + """Mock ConfigEntry that's been added to HA.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.config_entries.async_domains() + return mock_config_entry diff --git a/tests/components/hydrawise/test_device.py b/tests/components/hydrawise/test_device.py new file mode 100644 index 00000000000000..05c402faca779e --- /dev/null +++ b/tests/components/hydrawise/test_device.py @@ -0,0 +1,36 @@ +"""Tests for Hydrawise devices.""" + +from unittest.mock import Mock + +from homeassistant.components.hydrawise.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +def test_zones_in_device_registry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry, mock_pydrawise: Mock +) -> None: + """Test that devices are added to the device registry.""" + device_registry = dr.async_get(hass) + + device1 = device_registry.async_get_device(identifiers={(DOMAIN, "5965394")}) + assert device1 is not None + assert device1.name == "Zone One" + assert device1.manufacturer == "Hydrawise" + + device2 = device_registry.async_get_device(identifiers={(DOMAIN, "5965395")}) + assert device2 is not None + assert device2.name == "Zone Two" + assert device2.manufacturer == "Hydrawise" + + +def test_controller_in_device_registry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry, mock_pydrawise: Mock +) -> None: + """Test that devices are added to the device registry.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, "52496")}) + assert device is not None + assert device.name == "Home Controller" + assert device.manufacturer == "Hydrawise" diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py index 827481c60de7ce..02a61f0b20142d 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -27,6 +27,14 @@ async def fire_risk(self, api): ) return RCM("some place", 3, (0, 0)) + async def uv_risk(self, api): + """Mock UV Index.""" + UV = namedtuple( + "UV", + ["idPeriodo", "intervaloHora", "data", "globalIdLocal", "iUv"], + ) + return UV(0, "0", datetime.now(), 0, 5.7) + async def observation(self, api): """Mock Observation.""" Observation = namedtuple( diff --git a/tests/components/ipma/test_sensor.py b/tests/components/ipma/test_sensor.py index cbbad9c590fa71..d5f6a3ab5bb535 100644 --- a/tests/components/ipma/test_sensor.py +++ b/tests/components/ipma/test_sensor.py @@ -10,10 +10,7 @@ async def test_ipma_fire_risk_create_sensors(hass): """Test creation of fire risk sensors.""" - with patch( - "pyipma.location.Location.get", - return_value=MockLocation(), - ): + with patch("pyipma.location.Location.get", return_value=MockLocation()): entry = MockConfigEntry(domain="ipma", data=ENTRY_CONFIG) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -22,3 +19,17 @@ async def test_ipma_fire_risk_create_sensors(hass): state = hass.states.get("sensor.hometown_fire_risk") assert state.state == "3" + + +async def test_ipma_uv_index_create_sensors(hass): + """Test creation of uv index sensors.""" + + with patch("pyipma.location.Location.get", return_value=MockLocation()): + entry = MockConfigEntry(domain="ipma", data=ENTRY_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hometown_uv_index") + + assert state.state == "6" diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index b1cf8f2c9a5f7b..6b3b112e042d20 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -9,7 +9,9 @@ from homeassistant import config_entries from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import ( NEW_PRAYER_TIMES, @@ -145,3 +147,46 @@ async def test_update(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() assert pt_data.data == NEW_PRAYER_TIMES_TIMESTAMPS + + +@pytest.mark.parametrize( + ("object_id", "old_unique_id"), + [ + ( + "fajer_prayer", + "Fajr", + ), + ( + "dhuhr_prayer", + "Dhuhr", + ), + ], +) +async def test_migrate_unique_id( + hass: HomeAssistant, object_id: str, old_unique_id: str +) -> None: + """Test unique id migration.""" + entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) + entry.add_to_hass(hass) + + ent_reg = er.async_get(hass) + + entity: er.RegistryEntry = ent_reg.async_get_or_create( + suggested_object_id=object_id, + domain=SENSOR_DOMAIN, + platform=islamic_prayer_times.DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + assert entity.unique_id == old_unique_id + + with patch( + "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ), freeze_time(NOW): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = ent_reg.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{entry.entry_id}-{old_unique_id}" diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index a5b9b9c8a8d46f..e7f3759f993013 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -44,7 +44,6 @@ async def test_islamic_prayer_times_sensors( ), freeze_time(NOW): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert ( hass.states.get(sensor_name).state == PRAYER_TIMES_TIMESTAMPS[key].astimezone(dt_util.UTC).isoformat() diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 76a9544552fa17..5e5d46af4a65e4 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -138,6 +138,24 @@ async def test_knx_project_file_remove( assert not hass.data[DOMAIN].project.loaded +async def test_knx_get_project( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + load_knxproj: None, +): + """Test retrieval of kxnproject from store.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + assert hass.data[DOMAIN].project.loaded + + await client.send_json({"id": 3, "type": "knx/get_knx_project"}) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["project_loaded"] is True + assert res["result"]["knxproject"] == FIXTURE_PROJECT_DATA + + async def test_knx_group_monitor_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ): diff --git a/tests/components/medcom_ble/__init__.py b/tests/components/medcom_ble/__init__.py new file mode 100644 index 00000000000000..e38b8ce8f01daf --- /dev/null +++ b/tests/components/medcom_ble/__init__.py @@ -0,0 +1,111 @@ +"""Tests for the Medcom Inspector BLE integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from medcom_ble import MedcomBleDevice, MedcomBleDeviceData + +from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak + +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device + + +def patch_async_setup_entry(return_value=True): + """Patch async setup entry to return True.""" + return patch( + "homeassistant.components.medcom_ble.async_setup_entry", + return_value=return_value, + ) + + +def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak | None): + """Patch async ble device from address to return a given value.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + +def patch_medcom_ble(return_value=MedcomBleDevice, side_effect=None): + """Patch medcom-ble device fetcher with given values and effects.""" + return patch.object( + MedcomBleDeviceData, + "update_device", + return_value=return_value, + side_effect=side_effect, + ) + + +MEDCOM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="InspectorBLE-D9A0", + address="a0:d9:5a:57:0b:00", + device=generate_ble_device( + address="a0:d9:5a:57:0b:00", + name="InspectorBLE-D9A0", + ), + rssi=-54, + manufacturer_data={}, + service_data={ + # Sensor data + "d68236af-266f-4486-b42d-80356ed5afb7": bytearray(b" 45,"), + # Manufacturer + "00002a29-0000-1000-8000-00805f9b34fb": bytearray(b"International Medcom"), + # Model + "00002a24-0000-1000-8000-00805f9b34fb": bytearray(b"Inspector-BLE"), + # Identifier + "00002a25-0000-1000-8000-00805f9b34fb": bytearray(b"\xa0\xd9\x5a\x57\x0b\x00"), + # SW Version + "00002a26-0000-1000-8000-00805f9b34fb": bytearray(b"170602"), + # HW Version + "00002a27-0000-1000-8000-00805f9b34fb": bytearray(b"2.0"), + }, + service_uuids=[ + "39b31fec-b63a-4ef7-b163-a7317872007f", + "00002a29-0000-1000-8000-00805f9b34fb", + "00002a24-0000-1000-8000-00805f9b34fb", + "00002a25-0000-1000-8000-00805f9b34fb", + "00002a26-0000-1000-8000-00805f9b34fb", + "00002a27-0000-1000-8000-00805f9b34fb", + ], + source="local", + advertisement=generate_advertisement_data( + tx_power=8, + service_uuids=["39b31fec-b63a-4ef7-b163-a7317872007f"], + ), + connectable=True, + time=0, +) + +UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak( + name="unknown", + address="00:cc:cc:cc:cc:cc", + rssi=-61, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", + device=generate_ble_device( + "00:cc:cc:cc:cc:cc", + "unknown", + ), + advertisement=generate_advertisement_data( + manufacturer_data={}, + service_uuids=[], + ), + connectable=True, + time=0, +) + +MEDCOM_DEVICE_INFO = MedcomBleDevice( + manufacturer="International Medcom", + hw_version="2.0", + sw_version="170602", + model="Inspector BLE", + model_raw="InspectorBLE-D9A0", + name="Inspector BLE", + identifier="a0d95a570b00", + sensors={ + "cpm": 45, + }, + address="a0:d9:5a:57:0b:00", +) diff --git a/tests/components/medcom_ble/conftest.py b/tests/components/medcom_ble/conftest.py new file mode 100644 index 00000000000000..7c5b0dad22e3ab --- /dev/null +++ b/tests/components/medcom_ble/conftest.py @@ -0,0 +1,8 @@ +"""Common fixtures for the Medcom Inspector BLE tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/medcom_ble/test_config_flow.py b/tests/components/medcom_ble/test_config_flow.py new file mode 100644 index 00000000000000..620b6811757f81 --- /dev/null +++ b/tests/components/medcom_ble/test_config_flow.py @@ -0,0 +1,218 @@ +"""Test the Medcom Inspector BLE config flow.""" +from unittest.mock import patch + +from bleak import BleakError +from medcom_ble import MedcomBleDevice + +from homeassistant import config_entries +from homeassistant.components.medcom_ble.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + MEDCOM_DEVICE_INFO, + MEDCOM_SERVICE_INFO, + UNKNOWN_SERVICE_INFO, + patch_async_ble_device_from_address, + patch_async_setup_entry, + patch_medcom_ble, +) + +from tests.common import MockConfigEntry + + +async def test_bluetooth_discovery(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MEDCOM_SERVICE_INFO, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["description_placeholders"] == {"name": "InspectorBLE-D9A0"} + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + MedcomBleDevice( + manufacturer="International Medcom", + model="Inspector BLE", + model_raw="Inspector-BLE", + name="Inspector BLE", + identifier="a0d95a570b00", + ) + ): + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"not": "empty"} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "InspectorBLE-D9A0" + assert result["result"].unique_id == "a0:d9:5a:57:0b:00" + + +async def test_bluetooth_discovery_already_setup(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device when already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="a0:d9:5a:57:0b:00", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MEDCOM_DEVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_setup(hass: HomeAssistant) -> None: + """Test the user initiated form.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[MEDCOM_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "a0:d9:5a:57:0b:00": "InspectorBLE-D9A0" + } + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + MedcomBleDevice( + manufacturer="International Medcom", + model="Inspector BLE", + model_raw="Inspector-BLE", + name="Inspector BLE", + identifier="a0d95a570b00", + ) + ), patch( + "homeassistant.components.medcom_ble.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "InspectorBLE-D9A0" + assert result["result"].unique_id == "a0:d9:5a:57:0b:00" + + +async def test_user_setup_no_device(hass: HomeAssistant) -> None: + """Test the user initiated form without any device detected.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_setup_existing_and_unknown_device(hass: HomeAssistant) -> None: + """Test the user initiated form with existing devices and unknown ones.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="00:cc:cc:cc:cc:cc", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[UNKNOWN_SERVICE_INFO, MEDCOM_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_setup_unknown_device(hass: HomeAssistant) -> None: + """Test the user initiated form with only unknown devices.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[UNKNOWN_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_setup_unknown_error(hass: HomeAssistant) -> None: + """Test the user initiated form with an unknown error.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[MEDCOM_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + None, Exception() + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_user_setup_unable_to_connect(hass: HomeAssistant) -> None: + """Test the user initiated form with a device that's failing connection.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[MEDCOM_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "a0:d9:5a:57:0b:00": "InspectorBLE-D9A0" + } + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + side_effect=BleakError("An error") + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/media_extractor/snapshots/test_init.ambr b/tests/components/media_extractor/snapshots/test_init.ambr index 571b64df91431a..d70c370b60c374 100644 --- a/tests/components/media_extractor/snapshots/test_init.ambr +++ b/tests/components/media_extractor/snapshots/test_init.ambr @@ -6,7 +6,7 @@ ]), 'extra': dict({ }), - 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8', + 'media_content_id': 'https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUiMmQEGPqT5Hb00S74LeTwF4PCN31mwbC_fUNSejdsQCIF2D11o2OXBxoLlOX00vyB1wfYLIo6dBnodrfYc9gH6y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D', 'media_content_type': 'VIDEO', }) # --- @@ -87,7 +87,7 @@ 'entity_id': 'media_player.bedroom', 'extra': dict({ }), - 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALAASH0_ZDQQoMA82qWNCXSHPZ0bb9TQldIs7AAxktiiAiASA5bQy7IAa6NwdGIOpfye5OgcY_BNuo0WgSdh84tosw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIsDcLGH8KJpQpBgyJ5VWlDxfr75HyO8hMSVS9v7nRu4AiEA2xjtLZOzeNFoJlxwCsH3YqsUQt-BF_4gikhi_P4FbBc%3D/playlist/index.m3u8', + 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783146&fvip=2&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAO2IJciEtkI3PvYyVC_zkyo61I70wYJQXuGOMueeacrKAiA-UAdaJSlqqkfaa6QtqVnC_BJJZn7BXs85gh_fdbGoSg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D', 'media_content_type': 'VIDEO', }) # --- @@ -105,7 +105,7 @@ 'entity_id': 'media_player.bedroom', 'extra': dict({ }), - 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8', + 'media_content_id': 'https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUiMmQEGPqT5Hb00S74LeTwF4PCN31mwbC_fUNSejdsQCIF2D11o2OXBxoLlOX00vyB1wfYLIo6dBnodrfYc9gH6y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D', 'media_content_type': 'VIDEO', }) # --- @@ -114,7 +114,7 @@ 'entity_id': 'media_player.bedroom', 'extra': dict({ }), - 'media_content_id': 'https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=243&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=104373&dur=9.009&lmt=1660945832037331&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMLnlCaLvJ2scyVr6qYrCp3rzn_Op9eerIVWyp62NXKIAiEAnswRfxH5KssHQAKETF2MPncVWX_eDgpTXBEHN589-Xo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D', + 'media_content_id': 'https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=18&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=9.055&lmt=1665508348849369&mt=1694796392&fvip=5&fexp=24007246&beids=24350017&c=ANDROID&txp=4438434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALn143d2vS16xd_ndXj_rB8QOeHSCHC9YxSeOaRMF9eWAiAaYxqrRyV5bREBHLPCrs8Wk8Msm3hJrj11OJc2RIEyzw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D', 'media_content_type': 'VIDEO', }) # --- diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py index 3f635fbe333eb0..c7eb0e4b096058 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -7,6 +7,8 @@ ) TEST_HOST = "mc.dummyserver.com" +TEST_PORT = 25566 +TEST_ADDRESS = f"{TEST_HOST}:{TEST_PORT}" TEST_JAVA_STATUS_RESPONSE_RAW = { "description": {"text": "Dummy Description"}, diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 463a78b468081a..88afa6576d588a 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -1,59 +1,20 @@ """Tests for the Minecraft Server config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch -import aiodns +from mcstatus import JavaServer -from homeassistant.components.minecraft_server.const import ( - DEFAULT_NAME, - DEFAULT_PORT, - DOMAIN, -) +from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import TEST_HOST, TEST_JAVA_STATUS_RESPONSE - - -class QueryMock: - """Mock for result of aiodns.DNSResolver.query.""" - - def __init__(self) -> None: - """Set up query result mock.""" - self.host = TEST_HOST - self.port = 23456 - self.priority = 1 - self.weight = 1 - self.ttl = None - +from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT USER_INPUT = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"{TEST_HOST}:{DEFAULT_PORT}", -} - -USER_INPUT_SRV = {CONF_NAME: DEFAULT_NAME, CONF_HOST: TEST_HOST} - -USER_INPUT_IPV4 = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"1.1.1.1:{DEFAULT_PORT}", -} - -USER_INPUT_IPV6 = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"[::ffff:0101:0101]:{DEFAULT_PORT}", -} - -USER_INPUT_PORT_TOO_SMALL = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"{TEST_HOST}:1023", -} - -USER_INPUT_PORT_TOO_LARGE = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"{TEST_HOST}:65536", + CONF_ADDRESS: TEST_ADDRESS, } @@ -67,39 +28,25 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_port_too_small(hass: HomeAssistant) -> None: - """Test error in case of a too small port.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_SMALL - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_port"} - - -async def test_port_too_large(hass: HomeAssistant) -> None: - """Test error in case of a too large port.""" +async def test_lookup_failed(hass: HomeAssistant) -> None: + """Test error in case of a failed connection.""" with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, + "mcstatus.server.JavaServer.async_lookup", + side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_LARGE + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_port"} + assert result["errors"] == {"base": "cannot_connect"} async def test_connection_failed(hass: HomeAssistant) -> None: """Test error in case of a failed connection.""" with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, + "mcstatus.server.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch("mcstatus.server.JavaServer.async_status", side_effect=OSError): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -109,30 +56,11 @@ async def test_connection_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_connection_succeeded_with_srv_record(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection with a SRV record.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=AsyncMock(return_value=[QueryMock()]), - ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_SRV - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT_SRV[CONF_HOST] - assert result["data"][CONF_NAME] == USER_INPUT_SRV[CONF_NAME] - assert result["data"][CONF_HOST] == USER_INPUT_SRV[CONF_HOST] - - -async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: +async def test_connection_succeeded(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection with a host name.""" with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, + "mcstatus.server.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "mcstatus.server.JavaServer.async_status", return_value=TEST_JAVA_STATUS_RESPONSE, @@ -142,44 +70,6 @@ async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT[CONF_HOST] + assert result["title"] == USER_INPUT[CONF_ADDRESS] assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] - assert result["data"][CONF_HOST] == TEST_HOST - - -async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection with an IPv4 address.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, - ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT_IPV4[CONF_HOST] - assert result["data"][CONF_NAME] == USER_INPUT_IPV4[CONF_NAME] - assert result["data"][CONF_HOST] == "1.1.1.1" - - -async def test_connection_succeeded_with_ip6(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection with an IPv6 address.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, - ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV6 - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT_IPV6[CONF_HOST] - assert result["data"][CONF_NAME] == USER_INPUT_IPV6[CONF_NAME] - assert result["data"][CONF_HOST] == "::ffff:0101:0101" + assert result["data"][CONF_ADDRESS] == TEST_ADDRESS diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 77b6901a0a2ce5..1e3679fb1e3328 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -1,24 +1,20 @@ """Tests for the Minecraft Server integration.""" from unittest.mock import patch -import aiodns +from mcstatus import JavaServer from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.minecraft_server.const import ( - DEFAULT_NAME, - DEFAULT_PORT, - DOMAIN, -) +from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import TEST_HOST, TEST_JAVA_STATUS_RESPONSE +from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT from tests.common import MockConfigEntry -TEST_UNIQUE_ID = f"{TEST_HOST}-{DEFAULT_PORT}" +TEST_UNIQUE_ID = f"{TEST_HOST}-{TEST_PORT}" SENSOR_KEYS = [ {"v1": "Latency Time", "v2": "latency"}, @@ -32,43 +28,54 @@ BINARY_SENSOR_KEYS = {"v1": "Status", "v2": "status"} -async def test_entry_migration_v1_to_v2(hass: HomeAssistant) -> None: - """Test entry migration from version 1 to 2.""" - - # Create mock config entry. +def create_v1_mock_config_entry(hass: HomeAssistant) -> int: + """Create mock config entry.""" config_entry_v1 = MockConfigEntry( domain=DOMAIN, unique_id=TEST_UNIQUE_ID, data={ CONF_NAME: DEFAULT_NAME, CONF_HOST: TEST_HOST, - CONF_PORT: DEFAULT_PORT, + CONF_PORT: TEST_PORT, }, version=1, ) config_entry_id = config_entry_v1.entry_id config_entry_v1.add_to_hass(hass) - # Create mock device entry. + return config_entry_id + + +def create_v1_mock_device_entry(hass: HomeAssistant, config_entry_id: int) -> int: + """Create mock device entry.""" device_registry = dr.async_get(hass) device_entry_v1 = device_registry.async_get_or_create( config_entry_id=config_entry_id, identifiers={(DOMAIN, TEST_UNIQUE_ID)}, ) device_entry_id = device_entry_v1.id + assert device_entry_v1 assert device_entry_id - # Create mock sensor entity entries. + return device_entry_id + + +def create_v1_mock_sensor_entity_entries( + hass: HomeAssistant, config_entry_id: int, device_entry_id: int +) -> list[dict]: + """Create mock sensor entity entries.""" sensor_entity_id_key_mapping_list = [] + config_entry = hass.config_entries.async_get_entry(config_entry_id) entity_registry = er.async_get(hass) + for sensor_key in SENSOR_KEYS: entity_unique_id = f"{TEST_UNIQUE_ID}-{sensor_key['v1']}" entity_entry_v1 = entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, unique_id=entity_unique_id, - config_entry=config_entry_v1, + config_entry=config_entry, device_id=device_entry_id, ) assert entity_entry_v1.unique_id == entity_unique_id @@ -76,25 +83,51 @@ async def test_entry_migration_v1_to_v2(hass: HomeAssistant) -> None: {"entity_id": entity_entry_v1.entity_id, "key": sensor_key["v2"]} ) - # Create mock binary sensor entity entry. + return sensor_entity_id_key_mapping_list + + +def create_v1_mock_binary_sensor_entity_entry( + hass: HomeAssistant, config_entry_id: int, device_entry_id: int +) -> dict: + """Create mock binary sensor entity entry.""" + config_entry = hass.config_entries.async_get_entry(config_entry_id) + entity_registry = er.async_get(hass) entity_unique_id = f"{TEST_UNIQUE_ID}-{BINARY_SENSOR_KEYS['v1']}" - entity_entry_v1 = entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( BINARY_SENSOR_DOMAIN, DOMAIN, unique_id=entity_unique_id, - config_entry=config_entry_v1, + config_entry=config_entry, device_id=device_entry_id, ) - assert entity_entry_v1.unique_id == entity_unique_id + assert entity_entry.unique_id == entity_unique_id binary_sensor_entity_id_key_mapping = { - "entity_id": entity_entry_v1.entity_id, + "entity_id": entity_entry.entity_id, "key": BINARY_SENSOR_KEYS["v2"], } + return binary_sensor_entity_id_key_mapping + + +async def test_entry_migration(hass: HomeAssistant) -> None: + """Test entry migration from version 1 to 3, where host and port is required for the connection to the server.""" + config_entry_id = create_v1_mock_config_entry(hass) + device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) + sensor_entity_id_key_mapping_list = create_v1_mock_sensor_entity_entries( + hass, config_entry_id, device_entry_id + ) + binary_sensor_entity_id_key_mapping = create_v1_mock_binary_sensor_entity_entry( + hass, config_entry_id, device_entry_id + ) + # Trigger migration. with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, + "mcstatus.server.JavaServer.lookup", + side_effect=[ + ValueError, + JavaServer(host=TEST_HOST, port=TEST_PORT), + JavaServer(host=TEST_HOST, port=TEST_PORT), + ], ), patch( "mcstatus.server.JavaServer.async_status", return_value=TEST_JAVA_STATUS_RESPONSE, @@ -103,29 +136,84 @@ async def test_entry_migration_v1_to_v2(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Test migrated config entry. - config_entry_v2 = hass.config_entries.async_get_entry(config_entry_id) - assert config_entry_v2.unique_id is None - assert config_entry_v2.data == { + config_entry = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry.unique_id is None + assert config_entry.data == { CONF_NAME: DEFAULT_NAME, - CONF_HOST: TEST_HOST, - CONF_PORT: DEFAULT_PORT, + CONF_ADDRESS: TEST_ADDRESS, } - assert config_entry_v2.version == 2 + assert config_entry.version == 3 # Test migrated device entry. - device_entry_v2 = device_registry.async_get(device_entry_id) - assert device_entry_v2.identifiers == {(DOMAIN, config_entry_id)} + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_entry_id) + assert device_entry.identifiers == {(DOMAIN, config_entry_id)} # Test migrated sensor entity entries. + entity_registry = er.async_get(hass) for mapping in sensor_entity_id_key_mapping_list: - entity_entry_v2 = entity_registry.async_get(mapping["entity_id"]) - assert entity_entry_v2.unique_id == f"{config_entry_id}-{mapping['key']}" + entity_entry = entity_registry.async_get(mapping["entity_id"]) + assert entity_entry.unique_id == f"{config_entry_id}-{mapping['key']}" # Test migrated binary sensor entity entry. - entity_entry_v2 = entity_registry.async_get( + entity_entry = entity_registry.async_get( binary_sensor_entity_id_key_mapping["entity_id"] ) assert ( - entity_entry_v2.unique_id + entity_entry.unique_id == f"{config_entry_id}-{binary_sensor_entity_id_key_mapping['key']}" ) + + +async def test_entry_migration_host_only(hass: HomeAssistant) -> None: + """Test entry migration from version 1 to 3, where host alone is sufficient for the connection to the server.""" + config_entry_id = create_v1_mock_config_entry(hass) + device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) + create_v1_mock_sensor_entity_entries(hass, config_entry_id, device_entry_id) + create_v1_mock_binary_sensor_entity_entry(hass, config_entry_id, device_entry_id) + + # Trigger migration. + with patch( + "mcstatus.server.JavaServer.lookup", + side_effect=[ + JavaServer(host=TEST_HOST, port=TEST_PORT), + JavaServer(host=TEST_HOST, port=TEST_PORT), + ], + ), patch( + "mcstatus.server.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ): + assert await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + + # Test migrated config entry. + config_entry = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry.unique_id is None + assert config_entry.data == { + CONF_NAME: DEFAULT_NAME, + CONF_ADDRESS: TEST_HOST, + } + assert config_entry.version == 3 + + +async def test_entry_migration_v3_failure(hass: HomeAssistant) -> None: + """Test failed entry migration from version 2 to 3.""" + config_entry_id = create_v1_mock_config_entry(hass) + device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) + create_v1_mock_sensor_entity_entries(hass, config_entry_id, device_entry_id) + create_v1_mock_binary_sensor_entity_entry(hass, config_entry_id, device_entry_id) + + # Trigger migration. + with patch( + "mcstatus.server.JavaServer.lookup", + side_effect=[ + ValueError, + ValueError, + ], + ): + assert not await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + + # Test config entry. + config_entry = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry.version == 2 diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index abcd6e8f3ee11b..37a17ac9a4103e 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -13,6 +13,7 @@ from homeassistant.helpers import device_registry as dr from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -42,6 +43,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -668,3 +670,73 @@ async def test_entity_name( await help_test_entity_name( hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + event.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) + + +@pytest.mark.freeze_time("2023-09-01 00:00:00+00:00") +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_skipped_async_ha_write_state2( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a write state command is only called when there is a valid event.""" + await mqtt_mock_entry() + topic = "test-topic" + payload1 = '{"event_type": "press"}' + payload2 = '{"event_type": "unknown"}' + with patch( + "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + ) as mock_async_ha_write_state: + assert len(mock_async_ha_write_state.mock_calls) == 0 + async_fire_mqtt_message(hass, topic, payload1) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 1 + + freezer.move_to("2023-09-01 00:00:10+00:00") + async_fire_mqtt_message(hass, topic, payload1) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 + + freezer.move_to("2023-09-01 00:00:20+00:00") + async_fire_mqtt_message(hass, topic, payload2) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 + + freezer.move_to("2023-09-01 00:00:30+00:00") + async_fire_mqtt_message(hass, topic, payload2) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 803a0d747666b7..fe354817aefbd8 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -60,6 +60,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -2244,3 +2245,48 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + fan.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "direction_state_topic": "direction-state-topic", + "percentage_state_topic": "percentage-state-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_modes": ["eco", "silent"], + "oscillation_state_topic": "oscillation-state-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("state-topic", "ON", "OFF"), + ("direction-state-topic", "forward", "reverse"), + ("percentage-state-topic", "30", "40"), + ("preset-mode-state-topic", "eco", "silent"), + ("oscillation-state-topic", "oscillate_on", "oscillate_off"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 0cc4d93684107f..4d2637a264f2ce 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -38,6 +38,7 @@ from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -60,6 +61,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1569,3 +1571,51 @@ async def test_unload_config_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + humidifier.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "action_topic": "action-topic", + "target_humidity_state_topic": "target-humidity-state-topic", + "current_humidity_topic": "current-humidity-topic", + "mode_command_topic": "mode-command-topic", + "mode_state_topic": "mode-state-topic", + "modes": [ + "comfort", + "eco", + ], + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("state-topic", "ON", "OFF"), + ("action-topic", "idle", "humidifying"), + ("current-humidity-topic", "31", "32"), + ("target-humidity-state-topic", "30", "40"), + ("mode-state-topic", "comfort", "eco"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_image.py b/tests/components/mqtt/test_image.py index d5789880f73e39..621be984b7bb1e 100644 --- a/tests/components/mqtt/test_image.py +++ b/tests/components/mqtt/test_image.py @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -34,6 +35,7 @@ help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -810,3 +812,37 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + image.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index e3a12a2c24ec80..ccd175fe2967f0 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2085,7 +2085,9 @@ def _callback(args) -> None: callbacks.append(args) mock_mqtt = await mqtt_mock_entry() - msg = ReceiveMessage("some-topic", b"test-payload", 1, False) + msg = ReceiveMessage( + "some-topic", b"test-payload", 1, False, "some-topic", datetime.now() + ) mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 0) await mqtt.async_subscribe(hass, "some-topic", _callback) mqtt_client_mock.on_message(mock_mqtt, None, msg) @@ -3898,3 +3900,44 @@ def _check_entities() -> int: assert state.state == "manual2_update_after_reload" assert (state := hass.states.get("sensor.test_manual3")) is not None assert state.state is STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": [ + { + "sensor": { + "name": "test", + "state_topic": "test-topic", + } + }, + ] + } + ], +) +async def test_reload_with_invalid_config( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reloading yaml config fails.""" + await mqtt_mock_entry() + assert hass.states.get("sensor.test") is not None + + # Reload with an invalid config and assert again + invalid_config = {"mqtt": "some_invalid_config"} + with patch( + "homeassistant.config.load_yaml_config_file", return_value=invalid_config + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # Test nothing changed as loading the config failed + assert hass.states.get("sensor.test") is not None diff --git a/tests/components/mqtt/test_lawn_mower.py b/tests/components/mqtt/test_lawn_mower.py index b7130cac3bf6ab..85df2caef6c1ce 100644 --- a/tests/components/mqtt/test_lawn_mower.py +++ b/tests/components/mqtt/test_lawn_mower.py @@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -47,6 +48,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -886,3 +888,39 @@ async def test_persistent_state_after_reconfig( # assert the state persistent state = hass.states.get("lawn_mower.garden") assert state.state == "docked" + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + lawn_mower.DOMAIN, + DEFAULT_CONFIG, + ( + { + "activity_state_topic": "activity-state-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("activity-state-topic", "mowing", "paused"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 85e3bdd12b99d0..c7d17ed47a0869 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -63,6 +63,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -1099,3 +1100,43 @@ async def test_setup_manual_entity_from_yaml( await mqtt_mock_entry() platform = vacuum.DOMAIN assert hass.states.get(f"{platform}.mqtttest") + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("vacuum/state", '{"battery_level": 71}', '{"battery_level": 60}'), + ("vacuum/state", '{"docked": true}', '{"docked": false}'), + ("vacuum/state", '{"cleaning": true}', '{"cleaning": false}'), + ("vacuum/state", '{"fan_speed": "max"}', '{"fan_speed": "min"}'), + ("vacuum/state", '{"error": "some error"}', '{"error": "other error"}'), + ("vacuum/state", '{"charging": true}', '{"charging": false}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index bf7e1529a4e299..e128590c9072e5 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -22,6 +22,7 @@ ATTR_CODE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant @@ -50,6 +51,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -106,7 +108,7 @@ async def test_controlling_state_via_topic( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) assert not state.attributes.get(ATTR_SUPPORTED_FEATURES) @@ -124,6 +126,7 @@ async def test_controlling_state_via_topic( (CONFIG_WITH_STATES, "closing", STATE_LOCKING), (CONFIG_WITH_STATES, "open", STATE_UNLOCKED), (CONFIG_WITH_STATES, "opening", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "None", STATE_UNKNOWN), ], ) async def test_controlling_non_default_state_via_topic( @@ -136,7 +139,7 @@ async def test_controlling_non_default_state_via_topic( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "state-topic", payload) @@ -184,6 +187,15 @@ async def test_controlling_non_default_state_via_topic( '{"val":"open"}', STATE_UNLOCKED, ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":null}', + STATE_UNKNOWN, + ), ], ) async def test_controlling_state_via_topic_and_json_message( @@ -196,7 +208,7 @@ async def test_controlling_state_via_topic_and_json_message( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN async_fire_mqtt_message(hass, "state-topic", payload) @@ -255,7 +267,7 @@ async def test_controlling_non_default_state_via_topic_and_json_message( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN async_fire_mqtt_message(hass, "state-topic", payload) @@ -573,7 +585,7 @@ async def test_sending_mqtt_commands_pessimistic( mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == LockEntityFeature.OPEN # send lock command to lock @@ -1030,3 +1042,40 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("state-topic", "closed", "open"), + ("state-topic", "closed", "opening"), + ("state-topic", "open", "closing"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index dbdd373a659d85..c6590c71c4dc7c 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -31,6 +31,7 @@ from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -54,6 +55,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1140,3 +1142,39 @@ async def test_entity_name( await help_test_entity_name( hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + number.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "10", "20.7"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index f1903fa4c3c1e4..0c18881d86e2fd 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -25,6 +25,7 @@ from homeassistant.helpers.typing import ConfigType from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -48,6 +49,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -810,3 +812,39 @@ async def test_persistent_state_after_reconfig( state = hass.states.get("select.milk") assert state.state == "beer" assert state.attributes["options"] == ["beer"] + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + select.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "milk", "beer"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 7c448eba85ee1a..8a5760682160fc 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -44,6 +44,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -257,7 +258,7 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa async_fire_mqtt_message( hass, "state-topic", - '{"state":"beer off", "duration": 5, "volume_level": 0.6}', + '{"state":"beer off", "tone": "bell", "duration": 5, "volume_level": 0.6}', ) state = hass.states.get("siren.test") @@ -270,14 +271,15 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa async_fire_mqtt_message( hass, "state-topic", - '{"state":"beer on", "duration": 6, "volume_level": 2 }', + '{"state":"beer on", "duration": 6, "volume_level": 2,"tone": "ping"}', ) state = hass.states.get("siren.test") assert ( - "Unable to update siren state attributes from payload '{'duration': 6, 'volume_level': 2}': value must be at most 1 for dictionary value @ data['volume_level']" + "Unable to update siren state attributes from payload '{'duration': 6, 'volume_level': 2, 'tone': 'ping'}': value must be at most 1 for dictionary value @ data['volume_level']" in caplog.text ) - assert state.state == STATE_OFF + # Only the on/of state was updated, not the attributes + assert state.state == STATE_ON assert state.attributes.get(siren.ATTR_TONE) == "bell" assert state.attributes.get(siren.ATTR_DURATION) == 5 assert state.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.6 @@ -287,7 +289,7 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa "state-topic", "{}", ) - assert state.state == STATE_OFF + assert state.state == STATE_ON assert state.attributes.get(siren.ATTR_TONE) == "bell" assert state.attributes.get(siren.ATTR_DURATION) == 5 assert state.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.6 @@ -1091,3 +1093,53 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "available_tones": ["siren", "bell"], + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("test-topic", "ON", "OFF"), + ("test-topic", '{"state": "ON"}', '{"state": "OFF"}'), + ("test-topic", '{"state":"ON","tone":"siren"}', '{"state":"ON","tone":"bell"}'), + ( + "test-topic", + '{"state":"ON","tone":"siren"}', + '{"state":"OFF","tone":"siren"}', + ), + # Attriute volume_level 2 is invalid, but the state is valid and should update + ( + "test-topic", + '{"state":"ON","volume_level":0.5}', + '{"state":"OFF","volume_level":2}', + ), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index a24884941fc11f..40bd515828042c 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -58,6 +58,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -821,3 +822,40 @@ async def test_setup_manual_entity_from_yaml( await mqtt_mock_entry() platform = vacuum.DOMAIN assert hass.states.get(f"{platform}.mqtttest") + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("vacuum/state", '{"state": "cleaning"}', '{"state": "docked"}'), + ("vacuum/state", '{"battery_level": 71}', '{"battery_level": 60}'), + ("vacuum/state", '{"fan_speed": "max"}', '{"fan_speed": "min"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 4471cc7dc11faa..32195289aab87b 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -39,6 +40,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -762,3 +764,39 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + switch.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "ON", "OFF"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 9e068a078249fe..bf6fe1b0130651 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -38,6 +39,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -762,3 +764,39 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + text.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "My original text", "Changed text"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 9c881352f8c2bd..c5fe5abd8c4d77 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -33,6 +34,7 @@ help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -47,7 +49,7 @@ update.DOMAIN: { "name": "test", "state_topic": "test-topic", - "latest_version_topic": "test-topic", + "latest_version_topic": "latest-version-topic", "command_topic": "test-topic", "payload_install": "install", } @@ -730,3 +732,53 @@ async def test_reloadable( domain = update.DOMAIN config = DEFAULT_CONFIG await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + update.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("latest-version-topic", "1.1", "1.2"), + ("test-topic", "1.1", "1.2"), + ("test-topic", '{"installed_version": "1.1"}', '{"installed_version": "1.2"}'), + ("test-topic", '{"latest_version": "1.1"}', '{"latest_version": "1.2"}'), + ("test-topic", '{"title": "Update"}', '{"title": "Patch"}'), + ("test-topic", '{"release_summary": "bla1"}', '{"release_summary": "bla2"}'), + ( + "test-topic", + '{"release_url": "https://example.com/update?r=1"}', + '{"release_url": "https://example.com/update?r=2"}', + ), + ( + "test-topic", + '{"entity_picture": "https://example.com/icon1.png"}', + '{"entity_picture": "https://example.com/icon2.png"}', + ), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index a3f7dfcb9d2d74..0776b80a3cdff2 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -92,7 +92,11 @@ async def simulate_webhook(hass, webhook_id, response): @contextmanager def selected_platforms(platforms): """Restrict loaded platforms to list given.""" - with patch("homeassistant.components.netatmo.PLATFORMS", platforms), patch( + with patch( + "homeassistant.components.netatmo.data_handler.PLATFORMS", platforms + ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", - ), patch("homeassistant.components.netatmo.webhook_generate_url"): + ), patch( + "homeassistant.components.netatmo.webhook_generate_url" + ): yield diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 8bfe7176f5df28..e9a66cfefc818a 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -478,7 +478,7 @@ async def fake_post_no_data(*args, **kwargs): with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", ["camera"] + "homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( @@ -491,7 +491,7 @@ async def fake_post_no_data(*args, **kwargs): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert fake_post_hits == 11 + assert fake_post_hits == 8 async def test_camera_image_raises_exception( diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index c6146dca339550..e04295ae6684be 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -201,7 +201,7 @@ async def test_setup_with_cloud(hass: HomeAssistant, config_entry) -> None: ) as fake_delete_cloudhook, patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", [] + "homeassistant.components.netatmo.data_handler.PLATFORMS", [] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( @@ -267,7 +267,7 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: ) as fake_delete_cloudhook, patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", [] + "homeassistant.components.netatmo.data_handler.PLATFORMS", [] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 5a875097636433..83218b6d6d1f57 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -99,7 +99,7 @@ async def fake_post_request_no_data(*args, **kwargs): with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", ["light"] + "homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( @@ -120,7 +120,7 @@ async def fake_post_request_no_data(*args, **kwargs): ) await hass.async_block_till_done() - assert fake_post_hits == 4 + assert fake_post_hits == 3 assert hass.config_entries.async_entries(DOMAIN) assert len(hass.states.async_all()) == 0 diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 40b400210aae7a..dbc3456117c914 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -37,7 +37,7 @@ SERIAL_RESPONSE = "850000012635436566" ZERO_SERIAL_RESPONSE = "850000000000000000" # Model and version command 0x82 -MODEL_AND_VERSION_RESPONSE = "820006090C" +MODEL_AND_VERSION_RESPONSE = "820005090C" # ESP-TM2 # Get available stations command 0x83 AVAILABLE_STATIONS_RESPONSE = "83017F000000" # Mask for 7 zones EMPTY_STATIONS_RESPONSE = "830000000000" @@ -184,8 +184,15 @@ def mock_rain_delay_response() -> str: return RAIN_DELAY_OFF +@pytest.fixture(name="model_and_version_response") +def mock_model_and_version_response() -> str: + """Mock response to return rain delay state.""" + return MODEL_AND_VERSION_RESPONSE + + @pytest.fixture(name="api_responses") def mock_api_responses( + model_and_version_response: str, stations_response: str, zone_state_response: str, rain_response: str, @@ -196,7 +203,7 @@ def mock_api_responses( These are returned in the order they are requested by the update coordinator. """ return [ - MODEL_AND_VERSION_RESPONSE, + model_and_version_response, stations_response, zone_state_response, rain_response, diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py new file mode 100644 index 00000000000000..2028fccc24f7e7 --- /dev/null +++ b/tests/components/rainbird/test_calendar.py @@ -0,0 +1,272 @@ +"""Tests for rainbird calendar platform.""" + + +from collections.abc import Awaitable, Callable +import datetime +from http import HTTPStatus +from typing import Any +import urllib +from zoneinfo import ZoneInfo + +from aiohttp import ClientSession +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import ComponentSetup, mock_response, mock_response_error + +from tests.test_util.aiohttp import AiohttpClientMockResponse + +TEST_ENTITY = "calendar.rain_bird_controller" +GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] + +SCHEDULE_RESPONSES = [ + # Current controller status + "A0000000000000", + # Per-program information + "A00010060602006400", # CUSTOM: Monday & Tuesday + "A00011110602006400", + "A00012000300006400", + # Start times per program + "A0006000F0FFFFFFFFFFFF", # 4am + "A00061FFFFFFFFFFFFFFFF", + "A00062FFFFFFFFFFFFFFFF", + # Run times for each zone + "A00080001900000000001400000000", # zone1=25, zone2=20 + "A00081000700000000001400000000", # zone3=7, zone4=20 + "A00082000A00000000000000000000", # zone5=10 + "A00083000000000000000000000000", + "A00084000000000000000000000000", + "A00085000000000000000000000000", + "A00086000000000000000000000000", + "A00087000000000000000000000000", + "A00088000000000000000000000000", + "A00089000000000000000000000000", + "A0008A000000000000000000000000", +] + +EMPTY_SCHEDULE_RESPONSES = [ + # Current controller status + "A0000000000000", + # Per-program information (ignored) + "A00010000000000000", + "A00011000000000000", + "A00012000000000000", + # Start times for each program (off) + "A00060FFFFFFFFFFFFFFFF", + "A00061FFFFFFFFFFFFFFFF", + "A00062FFFFFFFFFFFFFFFF", + # Run times for each zone + "A00080000000000000000000000000", + "A00081000000000000000000000000", + "A00082000000000000000000000000", + "A00083000000000000000000000000", + "A00084000000000000000000000000", + "A00085000000000000000000000000", + "A00086000000000000000000000000", + "A00087000000000000000000000000", + "A00088000000000000000000000000", + "A00089000000000000000000000000", + "A0008A000000000000000000000000", +] + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CALENDAR] + + +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant): + """Set the time zone for the tests.""" + hass.config.set_time_zone("America/Regina") + + +@pytest.fixture(autouse=True) +def mock_schedule_responses() -> list[str]: + """Fixture containing fake irrigation schedule.""" + return SCHEDULE_RESPONSES + + +@pytest.fixture(autouse=True) +def mock_insert_schedule_response( + mock_schedule_responses: list[str], responses: list[AiohttpClientMockResponse] +) -> None: + """Fixture to insert device responses for the irrigation schedule.""" + responses.extend( + [mock_response(api_response) for api_response in mock_schedule_responses] + ) + + +@pytest.fixture(name="get_events") +def get_events_fixture( + hass_client: Callable[..., Awaitable[ClientSession]] +) -> GetEventsFn: + """Fetch calendar events from the HTTP API.""" + + async def _fetch(start: str, end: str) -> list[dict[str, Any]]: + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + ) + assert response.status == HTTPStatus.OK + results = await response.json() + return [{k: event[k] for k in {"summary", "start", "end"}} for event in results] + + return _fetch + + +@pytest.mark.freeze_time("2023-01-21 09:32:00") +async def test_get_events( + hass: HomeAssistant, setup_integration: ComponentSetup, get_events: GetEventsFn +) -> None: + """Test calendar event fetching APIs.""" + + assert await setup_integration() + + events = await get_events("2023-01-20T00:00:00Z", "2023-02-05T00:00:00Z") + assert events == [ + # Monday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-23T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-23T05:22:00-06:00"}, + }, + # Tuesday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-24T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-24T05:22:00-06:00"}, + }, + # Monday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-30T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-30T05:22:00-06:00"}, + }, + # Tuesday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-31T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-31T05:22:00-06:00"}, + }, + ] + + +@pytest.mark.parametrize( + ("freeze_time", "expected_state"), + [ + ( + datetime.datetime(2023, 1, 23, 3, 50, tzinfo=ZoneInfo("America/Regina")), + "off", + ), + ( + datetime.datetime(2023, 1, 23, 4, 30, tzinfo=ZoneInfo("America/Regina")), + "on", + ), + ], +) +async def test_event_state( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + freezer: FrozenDateTimeFactory, + freeze_time: datetime.datetime, + expected_state: str, +) -> None: + """Test calendar upcoming event state.""" + freezer.move_to(freeze_time) + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state is not None + assert state.attributes == { + "message": "PGM A", + "start_time": "2023-01-23 04:00:00", + "end_time": "2023-01-23 05:22:00", + "all_day": False, + "description": "", + "location": "", + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("model_and_version_response", "has_entity"), + [ + ("820005090C", True), + ("820006090C", False), + ], + ids=("ESP-TM2", "ST8x-WiFi"), +) +async def test_calendar_not_supported_by_device( + hass: HomeAssistant, + setup_integration: ComponentSetup, + has_entity: bool, +) -> None: + """Test calendar upcoming event state.""" + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert (state is not None) == has_entity + + +@pytest.mark.parametrize( + "mock_insert_schedule_response", [([None])] # Disable success responses +) +async def test_no_schedule( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + responses: list[AiohttpClientMockResponse], + hass_client: Callable[..., Awaitable[ClientSession]], +) -> None: + """Test calendar error when fetching the calendar.""" + responses.extend([mock_response_error(HTTPStatus.BAD_GATEWAY)]) # Arbitrary error + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state.state == "unavailable" + assert state.attributes == { + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } + + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start=2023-08-01&end=2023-08-02" + ) + assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.freeze_time("2023-01-21 09:32:00") +@pytest.mark.parametrize( + "mock_schedule_responses", + [(EMPTY_SCHEDULE_RESPONSES)], +) +async def test_program_schedule_disabled( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, +) -> None: + """Test calendar when the program is disabled with no upcoming events.""" + + assert await setup_integration() + + events = await get_events("2023-01-20T00:00:00Z", "2023-02-05T00:00:00Z") + assert events == [] + + state = hass.states.get(TEST_ENTITY) + assert state.state == "off" + assert state.attributes == { + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 2c837a75c66ef2..6ce7d10c9f2928 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -73,7 +73,7 @@ async def test_set_value( device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)}) assert device assert device.name == "Rain Bird Controller" - assert device.model == "ST8x-WiFi" + assert device.model == "ESP-TM2" assert device.sw_version == "9.12" aioclient_mock.mock_calls.clear() diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 9127a0b0c616e2..9ce5e799c92fd0 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -57,7 +57,6 @@ async def test_no_zones( async def test_zones( hass: HomeAssistant, setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], ) -> None: """Test switch platform with fake data that creates 7 zones with one enabled.""" diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ab89b82d713d89..e56b2b83274391 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -24,6 +24,7 @@ get_last_statistics, get_latest_short_term_statistics, get_metadata, + get_short_term_statistics_run_cache, list_statistic_ids, ) from homeassistant.components.recorder.table_managers.statistics_meta import ( @@ -176,6 +177,15 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) ) assert stats == {"sensor.test1": [expected_2]} + # Now wipe the latest_short_term_statistics_ids table and test again + # to make sure we can rebuild the missing data + run_cache = get_short_term_statistics_run_cache(instance.hass) + run_cache._latest_id_by_metadata_id = {} + stats = get_latest_short_term_statistics( + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} + ) + assert stats == {"sensor.test1": [expected_2]} + metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) stats = get_latest_short_term_statistics( @@ -220,6 +230,17 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) ) assert stats == {} + # Delete again, and manually wipe the cache since we deleted all the data + instance.get_session().query(StatisticsShortTerm).delete() + run_cache = get_short_term_statistics_run_cache(instance.hass) + run_cache._latest_id_by_metadata_id = {} + + # And test again to make sure there is no data + stats = get_latest_short_term_statistics( + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} + ) + assert stats == {} + @pytest.fixture def mock_sensor_statistics(): diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index a9dc23ef5b3afc..38b657945f7f34 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -15,6 +15,7 @@ async_add_external_statistics, get_last_statistics, get_metadata, + get_short_term_statistics_run_cache, list_statistic_ids, ) from homeassistant.components.recorder.websocket_api import UNIT_SCHEMA @@ -302,6 +303,13 @@ def next_id(): ) await async_wait_recording_done(hass) + metadata = get_metadata(hass, statistic_ids={"sensor.test"}) + metadata_id = metadata["sensor.test"][0] + run_cache = get_short_term_statistics_run_cache(hass) + # Verify the import of the short term statistics + # also updates the run cache + assert run_cache.get_latest_ids({metadata_id}) is not None + # No data for this period yet await client.send_json( { diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 7cf94dcf84668a..c43fe84ea8fdd4 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -1,11 +1,16 @@ """The tests for the rest command platform.""" import asyncio from http import HTTPStatus +from unittest.mock import patch import aiohttp import homeassistant.components.rest_command as rc -from homeassistant.const import CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_PLAIN +from homeassistant.const import ( + CONTENT_TYPE_JSON, + CONTENT_TYPE_TEXT_PLAIN, + SERVICE_RELOAD, +) from homeassistant.setup import setup_component from tests.common import assert_setup_component, get_test_home_assistant @@ -43,6 +48,30 @@ def test_setup_component_test_service(self): assert self.hass.services.has_service(rc.DOMAIN, "test_get") + def test_reload(self): + """Verify we can reload rest_command integration.""" + + with assert_setup_component(1): + setup_component(self.hass, rc.DOMAIN, self.config) + + assert self.hass.services.has_service(rc.DOMAIN, "test_get") + assert not self.hass.services.has_service(rc.DOMAIN, "new_test") + + new_config = { + rc.DOMAIN: { + "new_test": {"url": "https://example.org", "method": "get"}, + } + } + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=new_config, + ): + self.hass.services.call(rc.DOMAIN, SERVICE_RELOAD, blocking=True) + + assert self.hass.services.has_service(rc.DOMAIN, "new_test") + assert not self.hass.services.has_service(rc.DOMAIN, "get_test") + class TestRestCommandComponent: """Test the rest command component.""" diff --git a/tests/components/screenlogic/__init__.py b/tests/components/screenlogic/__init__.py index 48362722312f01..e5400e3ca15625 100644 --- a/tests/components/screenlogic/__init__.py +++ b/tests/components/screenlogic/__init__.py @@ -35,12 +35,21 @@ def num_key_string_to_int(data: dict) -> None: DATA_FULL_CHEM = num_key_string_to_int( load_json_object_fixture("screenlogic/data_full_chem.json") ) +DATA_FULL_NO_GPM = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_full_no_gpm.json") +) +DATA_FULL_NO_SALT_PPM = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_full_no_salt_ppm.json") +) DATA_MIN_MIGRATION = num_key_string_to_int( load_json_object_fixture("screenlogic/data_min_migration.json") ) DATA_MIN_ENTITY_CLEANUP = num_key_string_to_int( load_json_object_fixture("screenlogic/data_min_entity_cleanup.json") ) +DATA_MISSING_VALUES_CHEM_CHLOR = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_missing_values_chem_chlor.json") +) async def stub_async_connect( diff --git a/tests/components/screenlogic/fixtures/data_full_no_gpm.json b/tests/components/screenlogic/fixtures/data_full_no_gpm.json new file mode 100644 index 00000000000000..93e3040f911314 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_full_no_gpm.json @@ -0,0 +1,784 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 738.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 1, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 7, + "color_count": 8, + "color": [ + { + "name": "White", + "value": [255, 255, 255] + }, + { + "name": "Light Green", + "value": [160, 255, 160] + }, + { + "name": "Green", + "value": [0, 255, 80] + }, + { + "name": "Cyan", + "value": [0, 255, 200] + }, + { + "name": "Blue", + "value": [100, 140, 255] + }, + { + "name": "Lavender", + "value": [230, 130, 255] + }, + { + "name": "Magenta", + "value": [255, 0, 128] + }, + { + "name": "Light Magenta", + "value": [255, 180, 210] + } + ], + "interface_tab_flags": 127, + "show_alarms": 1, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "IntelliTouch i7+3" + }, + "equipment": { + "flags": 56, + "list": ["INTELLIBRITE", "INTELLIFLO_0", "INTELLIFLO_1"] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "freeze_mode": { + "name": "Freeze Mode", + "value": 0 + }, + "pool_delay": { + "name": "Pool Delay", + "value": 0 + }, + "spa_delay": { + "name": "Spa Delay", + "value": 0 + }, + "cleaner_delay": { + "name": "Cleaner Delay", + "value": 0 + }, + "air_temperature": { + "name": "Air Temperature", + "value": 91, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "ph": { + "name": "pH", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.0, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 0, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 0, + "state_type": "measurement" + }, + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": { + "500": { + "circuit_id": 500, + "name": "Spa", + "configuration": { + "name_index": 71, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_62": 0, + "unknown_at_offset_63": 0, + "delay": 0 + }, + "function": 1, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 1, + "value": 0 + }, + "501": { + "circuit_id": 501, + "name": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_90": 0, + "unknown_at_offset_91": 0, + "delay": 0 + }, + "function": 5, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Jets", + "configuration": { + "name_index": 45, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_114": 0, + "unknown_at_offset_115": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_146": 0, + "unknown_at_offset_147": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_178": 0, + "unknown_at_offset_179": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 0, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool", + "configuration": { + "name_index": 60, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_202": 0, + "unknown_at_offset_203": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 1 + }, + "506": { + "circuit_id": 506, + "name": "Air Blower", + "configuration": { + "name_index": 1, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_234": 0, + "unknown_at_offset_235": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + } + }, + "pump": { + "0": { + "data": 134, + "type": 2, + "state": { + "name": "Pool Pump", + "value": 1 + }, + "watts_now": { + "name": "Pool Pump Watts Now", + "value": 63, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Pump RPM Now", + "value": 1050, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 1050, + "is_rpm": 1 + }, + "1": { + "device_id": 1, + "setpoint": 1850, + "is_rpm": 1 + }, + "2": { + "device_id": 2, + "setpoint": 1500, + "is_rpm": 1 + }, + "3": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "4": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "5": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "6": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "7": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + } + } + }, + "1": { + "data": 131, + "type": 2, + "state": { + "name": "Jets Pump", + "value": 0 + }, + "watts_now": { + "name": "Jets Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Jets Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Jets Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 3, + "setpoint": 2970, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "2": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "3": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "4": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "5": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "6": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "7": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + } + } + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": { + "0": { + "body_type": 0, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Pool", + "last_temperature": { + "name": "Last Pool Temperature", + "value": 86, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Pool Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Pool Heat Set Point", + "value": 85, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Pool Cool Set Point", + "value": 100, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Pool Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + }, + "1": { + "body_type": 1, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Spa", + "last_temperature": { + "name": "Last Spa Temperature", + "value": 84, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Spa Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Spa Heat Set Point", + "value": 102, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 91, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 3, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + } + }, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 0, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 0, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.0, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 0, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 0.0, + "unit": "pH" + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 0, + "unit": "mV" + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 0, + "unit": "ppm" + }, + "cya": { + "name": "Cyanuric Acid", + "value": 0, + "unit": "ppm" + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 0, + "unit": "ppm" + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 0, + "unit": "ppm" + }, + "probe_is_celsius": 0, + "flags": 0 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 0, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 0, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 0, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 0, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 0, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 0, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 0, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 0, + "flow_alarm": { + "name": "Flow Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_high_alarm": { + "name": "pH HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_low_alarm": { + "name": "pH LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_high_alarm": { + "name": "ORP HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_low_alarm": { + "name": "ORP LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_supply_alarm": { + "name": "pH Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_supply_alarm": { + "name": "ORP Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "probe_fault_alarm": { + "name": "Probe Fault", + "value": 0, + "device_type": "alarm" + } + }, + "alert": { + "flags": 0, + "ph_lockout": { + "name": "pH Lockout", + "value": 0 + }, + "ph_limit": { + "name": "pH Dose Limit Reached", + "value": 0 + }, + "orp_limit": { + "name": "ORP Dose Limit Reached", + "value": 0 + } + }, + "firmware": { + "name": "IntelliChem Firmware", + "value": "0.000" + }, + "water_balance": { + "flags": 0, + "corrosive": { + "name": "SI Corrosive", + "value": 0, + "device_type": "alarm" + }, + "scaling": { + "name": "SI Scaling", + "value": 0, + "device_type": "alarm" + } + }, + "unknown_at_offset_44": 0, + "unknown_at_offset_45": 0, + "unknown_at_offset_46": 0 + }, + "scg": { + "scg_present": 0, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 1 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 50, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "spa_setpoint": { + "name": "Spa Chlorinator Setpoint", + "value": 0, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 1 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json new file mode 100644 index 00000000000000..d17d0e41170bdc --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json @@ -0,0 +1,859 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 11, + "color_count": 8, + "color": [ + { + "name": "White", + "value": [255, 255, 255] + }, + { + "name": "Light Green", + "value": [160, 255, 160] + }, + { + "name": "Green", + "value": [0, 255, 80] + }, + { + "name": "Cyan", + "value": [0, 255, 200] + }, + { + "name": "Blue", + "value": [100, 140, 255] + }, + { + "name": "Lavender", + "value": [230, 130, 255] + }, + { + "name": "Magenta", + "value": [255, 0, 128] + }, + { + "name": "Light Magenta", + "value": [255, 180, 210] + } + ], + "interface_tab_flags": 127, + "show_alarms": 0, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 60, + "list": ["CHLORINATOR", "INTELLIBRITE", "INTELLIFLO_0", "INTELLIFLO_1"] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "freeze_mode": { + "name": "Freeze Mode", + "value": 0 + }, + "pool_delay": { + "name": "Pool Delay", + "value": 0 + }, + "spa_delay": { + "name": "Spa Delay", + "value": 0 + }, + "cleaner_delay": { + "name": "Cleaner Delay", + "value": 0 + }, + "air_temperature": { + "name": "Air Temperature", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "ph": { + "name": "pH", + "value": 7.61, + "unit": "pH", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 728, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": { + "500": { + "circuit_id": 500, + "name": "Spa", + "configuration": { + "name_index": 71, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_62": 0, + "unknown_at_offset_63": 0, + "delay": 0 + }, + "function": 1, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 1, + "value": 0 + }, + "501": { + "circuit_id": 501, + "name": "Waterfall", + "configuration": { + "name_index": 85, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_94": 0, + "unknown_at_offset_95": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_126": 0, + "unknown_at_offset_127": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 2, + "color_position": 0, + "color_stagger": 2 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_158": 0, + "unknown_at_offset_159": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 6, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 240, + "unknown_at_offset_186": 0, + "unknown_at_offset_187": 0, + "delay": 0 + }, + "function": 5, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool Low", + "configuration": { + "name_index": 63, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_214": 0, + "unknown_at_offset_215": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 0 + }, + "506": { + "circuit_id": 506, + "name": "Yard Light", + "configuration": { + "name_index": 91, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_246": 0, + "unknown_at_offset_247": 0, + "delay": 0 + }, + "function": 7, + "interface": 4, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + }, + "507": { + "circuit_id": 507, + "name": "Cameras", + "configuration": { + "name_index": 101, + "flags": 0, + "default_runtime": 1620, + "unknown_at_offset_274": 0, + "unknown_at_offset_275": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 8, + "value": 1 + }, + "508": { + "circuit_id": 508, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_306": 0, + "unknown_at_offset_307": 0, + "delay": 0 + }, + "function": 0, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 9, + "value": 0 + }, + "510": { + "circuit_id": 510, + "name": "Spillway", + "configuration": { + "name_index": 78, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_334": 0, + "unknown_at_offset_335": 0, + "delay": 0 + }, + "function": 14, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 11, + "value": 0 + }, + "511": { + "circuit_id": 511, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_366": 0, + "unknown_at_offset_367": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 12, + "value": 0 + } + }, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Pool Low Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 63, + "is_rpm": 0 + }, + "1": { + "device_id": 9, + "setpoint": 72, + "is_rpm": 0 + }, + "2": { + "device_id": 1, + "setpoint": 3450, + "is_rpm": 1 + }, + "3": { + "device_id": 130, + "setpoint": 75, + "is_rpm": 0 + }, + "4": { + "device_id": 12, + "setpoint": 72, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "1": { + "data": 66, + "type": 3, + "state": { + "name": "Waterfall Pump", + "value": 0 + }, + "watts_now": { + "name": "Waterfall Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Waterfall Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Waterfall Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 2, + "setpoint": 2700, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "2": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "3": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "4": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": { + "0": { + "body_type": 0, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Pool", + "last_temperature": { + "name": "Last Pool Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Pool Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Pool Heat Set Point", + "value": 83, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Pool Cool Set Point", + "value": 100, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Pool Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + }, + "1": { + "body_type": 1, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Spa", + "last_temperature": { + "name": "Last Spa Temperature", + "value": 84, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Spa Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Spa Heat Set Point", + "value": 94, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + } + }, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 7.6, + "unit": "pH" + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 720, + "unit": "mV" + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 800, + "unit": "ppm" + }, + "cya": { + "name": "Cyanuric Acid", + "value": 45, + "unit": "ppm" + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 45, + "unit": "ppm" + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 1000, + "unit": "ppm" + }, + "probe_is_celsius": 0, + "flags": 32 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 5, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 4, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 149, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 1, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 2, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 1, + "flow_alarm": { + "name": "Flow Alarm", + "value": 1, + "device_type": "alarm" + }, + "ph_high_alarm": { + "name": "pH HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_low_alarm": { + "name": "pH LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_high_alarm": { + "name": "ORP HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_low_alarm": { + "name": "ORP LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_supply_alarm": { + "name": "pH Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_supply_alarm": { + "name": "ORP Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "probe_fault_alarm": { + "name": "Probe Fault", + "value": 0, + "device_type": "alarm" + } + }, + "alert": { + "flags": 0, + "ph_lockout": { + "name": "pH Lockout", + "value": 0 + }, + "ph_limit": { + "name": "pH Dose Limit Reached", + "value": 0 + }, + "orp_limit": { + "name": "ORP Dose Limit Reached", + "value": 0 + } + }, + "firmware": { + "name": "IntelliChem Firmware", + "value": "1.060" + }, + "water_balance": { + "flags": 0, + "corrosive": { + "name": "SI Corrosive", + "value": 0, + "device_type": "alarm" + }, + "scaling": { + "name": "SI Scaling", + "value": 0, + "device_type": "alarm" + } + }, + "unknown_at_offset_44": 0, + "unknown_at_offset_45": 0, + "unknown_at_offset_46": 0 + }, + "scg": { + "scg_present": 1, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 50, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json new file mode 100644 index 00000000000000..c30ee690f8a925 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json @@ -0,0 +1,849 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 11, + "color_count": 8, + "color": [ + { + "name": "White", + "value": [255, 255, 255] + }, + { + "name": "Light Green", + "value": [160, 255, 160] + }, + { + "name": "Green", + "value": [0, 255, 80] + }, + { + "name": "Cyan", + "value": [0, 255, 200] + }, + { + "name": "Blue", + "value": [100, 140, 255] + }, + { + "name": "Lavender", + "value": [230, 130, 255] + }, + { + "name": "Magenta", + "value": [255, 0, 128] + }, + { + "name": "Light Magenta", + "value": [255, 180, 210] + } + ], + "interface_tab_flags": 127, + "show_alarms": 0, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 32828, + "list": [ + "CHLORINATOR", + "INTELLIBRITE", + "INTELLIFLO_0", + "INTELLIFLO_1", + "INTELLICHEM" + ] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "pool_delay": { + "name": "Pool Delay", + "value": 0 + }, + "spa_delay": { + "name": "Spa Delay", + "value": 0 + }, + "cleaner_delay": { + "name": "Cleaner Delay", + "value": 0 + }, + "air_temperature": { + "name": "Air Temperature", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 728, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": { + "500": { + "circuit_id": 500, + "name": "Spa", + "configuration": { + "name_index": 71, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_62": 0, + "unknown_at_offset_63": 0, + "delay": 0 + }, + "function": 1, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 1, + "value": 0 + }, + "501": { + "circuit_id": 501, + "name": "Waterfall", + "configuration": { + "name_index": 85, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_94": 0, + "unknown_at_offset_95": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_126": 0, + "unknown_at_offset_127": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 2, + "color_position": 0, + "color_stagger": 2 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_158": 0, + "unknown_at_offset_159": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 6, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 240, + "unknown_at_offset_186": 0, + "unknown_at_offset_187": 0, + "delay": 0 + }, + "function": 5, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool Low", + "configuration": { + "name_index": 63, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_214": 0, + "unknown_at_offset_215": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 0 + }, + "506": { + "circuit_id": 506, + "name": "Yard Light", + "configuration": { + "name_index": 91, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_246": 0, + "unknown_at_offset_247": 0, + "delay": 0 + }, + "function": 7, + "interface": 4, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + }, + "507": { + "circuit_id": 507, + "name": "Cameras", + "configuration": { + "name_index": 101, + "flags": 0, + "default_runtime": 1620, + "unknown_at_offset_274": 0, + "unknown_at_offset_275": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 8, + "value": 1 + }, + "508": { + "circuit_id": 508, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_306": 0, + "unknown_at_offset_307": 0, + "delay": 0 + }, + "function": 0, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 9, + "value": 0 + }, + "510": { + "circuit_id": 510, + "name": "Spillway", + "configuration": { + "name_index": 78, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_334": 0, + "unknown_at_offset_335": 0, + "delay": 0 + }, + "function": 14, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 11, + "value": 0 + }, + "511": { + "circuit_id": 511, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_366": 0, + "unknown_at_offset_367": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 12, + "value": 0 + } + }, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 63, + "is_rpm": 0 + }, + "1": { + "device_id": 9, + "setpoint": 72, + "is_rpm": 0 + }, + "2": { + "device_id": 1, + "setpoint": 3450, + "is_rpm": 1 + }, + "3": { + "device_id": 130, + "setpoint": 75, + "is_rpm": 0 + }, + "4": { + "device_id": 12, + "setpoint": 72, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "1": { + "data": 66, + "type": 3, + "state": { + "name": "Waterfall Pump", + "value": 0 + }, + "watts_now": { + "name": "Waterfall Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Waterfall Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Waterfall Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 2, + "setpoint": 2700, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "2": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "3": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "4": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": { + "0": { + "body_type": 0, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Pool", + "last_temperature": { + "name": "Last Pool Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Pool Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Pool Heat Set Point", + "value": 83, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Pool Cool Set Point", + "value": 100, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Pool Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + }, + "1": { + "body_type": 1, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Spa", + "last_temperature": { + "name": "Last Spa Temperature", + "value": 84, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Spa Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Spa Heat Set Point", + "value": 94, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + } + }, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 7.6, + "unit": "pH" + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 720, + "unit": "mV" + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 800, + "unit": "ppm" + }, + "cya": { + "name": "Cyanuric Acid", + "value": 45, + "unit": "ppm" + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 45, + "unit": "ppm" + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 1000, + "unit": "ppm" + }, + "probe_is_celsius": 0, + "flags": 32 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 5, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 4, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 149, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 1, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 2, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 1, + "flow_alarm": { + "name": "Flow Alarm", + "value": 1, + "device_type": "alarm" + }, + "ph_high_alarm": { + "name": "pH HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_low_alarm": { + "name": "pH LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_high_alarm": { + "name": "ORP HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_low_alarm": { + "name": "ORP LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_supply_alarm": { + "name": "pH Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_supply_alarm": { + "name": "ORP Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "probe_fault_alarm": { + "name": "Probe Fault", + "value": 0, + "device_type": "alarm" + } + }, + "alert": { + "flags": 0, + "ph_lockout": { + "name": "pH Lockout", + "value": 0 + }, + "ph_limit": { + "name": "pH Dose Limit Reached", + "value": 0 + }, + "orp_limit": { + "name": "ORP Dose Limit Reached", + "value": 0 + } + }, + "firmware": { + "name": "IntelliChem Firmware", + "value": "1.060" + }, + "water_balance": { + "flags": 0, + "corrosive": { + "name": "SI Corrosive", + "value": 0, + "device_type": "alarm" + }, + "scaling": { + "name": "SI Scaling", + "value": 0, + "device_type": "alarm" + } + }, + "unknown_at_offset_44": 0, + "unknown_at_offset_45": 0, + "unknown_at_offset_46": 0 + }, + "scg": { + "scg_present": 1, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 50, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/test_data.py b/tests/components/screenlogic/test_data.py index 9686dc81586031..ead064f7d93b7a 100644 --- a/tests/components/screenlogic/test_data.py +++ b/tests/components/screenlogic/test_data.py @@ -1,12 +1,9 @@ """Tests for ScreenLogic integration data processing.""" from unittest.mock import DEFAULT, patch -import pytest from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from homeassistant.components.screenlogic import DOMAIN -from homeassistant.components.screenlogic.data import PathPart, realize_path_template from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -71,21 +68,3 @@ async def test_async_cleanup_entries( deleted_entity = entity_registry.async_get(unused_entity.entity_id) assert deleted_entity is None - - -def test_realize_path_templates() -> None: - """Test path template realization.""" - assert realize_path_template( - (PathPart.DEVICE, PathPart.INDEX), (DEVICE.PUMP, 0, VALUE.WATTS_NOW) - ) == (DEVICE.PUMP, 0) - - assert realize_path_template( - (PathPart.DEVICE, PathPart.INDEX, PathPart.VALUE, ATTR.NAME_INDEX), - (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION), - ) == (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION, ATTR.NAME_INDEX) - - with pytest.raises(KeyError): - realize_path_template( - (PathPart.DEVICE, PathPart.KEY, ATTR.VALUE), - (DEVICE.ADAPTER, VALUE.FIRMWARE), - ) diff --git a/tests/components/screenlogic/test_init.py b/tests/components/screenlogic/test_init.py index 3b99354a1df4f0..cf0a7ef3f38e3f 100644 --- a/tests/components/screenlogic/test_init.py +++ b/tests/components/screenlogic/test_init.py @@ -6,6 +6,7 @@ from screenlogicpy import ScreenLogicGateway from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.screenlogic import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -14,6 +15,7 @@ from . import ( DATA_MIN_MIGRATION, + DATA_MISSING_VALUES_CHEM_CHLOR, GATEWAY_DISCOVERY_IMPORT_PATH, MOCK_ADAPTER_MAC, MOCK_ADAPTER_NAME, @@ -77,6 +79,13 @@ class EntityMigrationData: "old_sensor", SENSOR_DOMAIN, ), + EntityMigrationData( + "Pump Sensor Missing Index", + "currentWatts", + "Pump Sensor Missing Index", + "currentWatts", + SENSOR_DOMAIN, + ), ] MIGRATION_CONNECT = lambda *args, **kwargs: stub_async_connect( @@ -234,3 +243,37 @@ async def test_entity_migration_data( entity_not_migrated = entity_registry.async_get(old_eid) assert entity_not_migrated == original_entity + + +async def test_platform_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test setup for platforms that define expected data.""" + stub_connect = lambda *args, **kwargs: stub_async_connect( + DATA_MISSING_VALUES_CHEM_CHLOR, *args, **kwargs + ) + + device_prefix = slugify(MOCK_ADAPTER_NAME) + + tested_entity_ids = [ + f"{BINARY_SENSOR_DOMAIN}.{device_prefix}_active_alert", + f"{SENSOR_DOMAIN}.{device_prefix}_air_temperature", + f"{NUMBER_DOMAIN}.{device_prefix}_pool_chlorinator_setpoint", + ] + + mock_config_entry.add_to_hass(hass) + + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=stub_connect, + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + for entity_id in tested_entity_ids: + assert hass.states.get(entity_id) is not None diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index a3f741272838b9..f6b4db84630fe0 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,5 +1,6 @@ """Tests for the Sonos config flow.""" import asyncio +from datetime import timedelta import logging from unittest.mock import Mock, patch @@ -17,9 +18,12 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util from .conftest import MockSoCo, SoCoMockFactory +from tests.common import async_fire_time_changed + async def test_creating_entry_sets_up_media_player( hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo @@ -322,16 +326,19 @@ async def test_async_poll_manual_hosts_5( # Speed up manual discovery interval so second iteration runs sooner mock_discovery_interval.total_seconds = Mock(side_effect=[0.5, 60]) - await _setup_hass(hass) - - assert "media_player.bedroom" in entity_registry.entities - assert "media_player.living_room" in entity_registry.entities - with caplog.at_level(logging.DEBUG): caplog.clear() - await speaker_1_activity.event.wait() - await speaker_2_activity.event.wait() + + await _setup_hass(hass) + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=0.5)) await hass.async_block_till_done() + await asyncio.gather( + *[speaker_1_activity.event.wait(), speaker_2_activity.event.wait()] + ) assert speaker_1_activity.call_count == 1 assert speaker_2_activity.call_count == 1 assert "Activity on Living Room" in caplog.text diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 1ae213e4bf1eb4..32d2d971d2c3bb 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -9,7 +9,6 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, - ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, ) @@ -29,7 +28,7 @@ async def test_loading_sensors(hass: HomeAssistant, init_integration) -> None: async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: """Test the SrpEntity.""" - usage_state = hass.states.get("sensor.home_energy_usage") + usage_state = hass.states.get("sensor.srp_energy_energy_usage") assert usage_state.state == "150.8" # Validate attributions @@ -43,7 +42,6 @@ async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: ) assert usage_state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - assert usage_state.attributes.get(ATTR_ICON) == "mdi:flash" async def test_srp_entity_update_failed( diff --git a/tests/components/stream/test_init.py b/tests/components/stream/test_init.py index 525eb9d859d59b..8372e5d5e61703 100644 --- a/tests/components/stream/test_init.py +++ b/tests/components/stream/test_init.py @@ -4,8 +4,8 @@ import av import pytest -from homeassistant.components.logger import EVENT_LOGGING_CHANGED from homeassistant.components.stream import __name__ as stream_name +from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 97965a5643ee3b..7ca3d11b09905e 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -1,4 +1,6 @@ """The tests for the Template Weather platform.""" +from typing import Any + import pytest from homeassistant.components.weather import ( @@ -18,8 +20,18 @@ SERVICE_GET_FORECAST, Forecast, ) -from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import ( + assert_setup_component, + async_mock_restore_state_shutdown_restart, + mock_restore_cache_with_extra_data, +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @@ -493,3 +505,457 @@ async def test_forecast_format_error( return_response=True, ) assert "Forecast in list is not a dict, see Weather documentation" in caplog.text + + +SAVED_EXTRA_DATA = { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_forecast": None, + "last_humidity": 10, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, +} + +SAVED_EXTRA_DATA_WITH_FUTURE_KEY = { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_forecast": None, + "last_humidity": 10, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, + "some_key_added_in_the_future": 123, +} + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ], +) +@pytest.mark.parametrize( + ("saved_state", "saved_extra_data", "initial_state"), + [ + ("sunny", SAVED_EXTRA_DATA, "sunny"), + ("sunny", SAVED_EXTRA_DATA_WITH_FUTURE_KEY, "sunny"), + (STATE_UNAVAILABLE, SAVED_EXTRA_DATA, STATE_UNKNOWN), + (STATE_UNKNOWN, SAVED_EXTRA_DATA, STATE_UNKNOWN), + ], +) +async def test_trigger_entity_restore_state( + hass: HomeAssistant, + count: int, + domain: str, + config: dict, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, +) -> None: + """Test restoring trigger template weather.""" + + restored_attributes = { # These should be ignored + "temperature": -10, + "humidity": 50, + } + + fake_state = State( + "weather.test", + saved_state, + restored_attributes, + ) + mock_restore_cache_with_extra_data(hass, ((fake_state, saved_extra_data),)) + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == initial_state + + hass.bus.async_fire( + "test_event", {"condition": "cloudy", "temperature": 15, "humidity": 25} + ) + await hass.async_block_till_done() + state = hass.states.get("weather.test") + + state = hass.states.get("weather.test") + assert state.state == "cloudy" + assert state.attributes["temperature"] == 15.0 + assert state.attributes["humidity"] == 25.0 + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + { + "variables": { + "my_variable": "{{ trigger.event.data.temperature + 1 }}" + }, + }, + ], + "weather": [ + { + "name": "Hello Name", + "condition_template": "sunny", + "temperature_unit": "°C", + "humidity_template": "{{ 20 }}", + "temperature_template": "{{ my_variable + 1 }}", + } + ], + }, + ], + }, + ], +) +async def test_trigger_action( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger entity with an action works.""" + state = hass.states.get("weather.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"temperature": 1}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("weather.hello_name") + assert state.state == "sunny" + assert state.attributes["temperature"] == 3.0 + assert state.context is context + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + { + "variables": { + "my_variable": "{{ trigger.event.data.information + 1 }}", + "var_forecast_daily": "{{ trigger.event.data.forecast_daily }}", + "var_forecast_hourly": "{{ trigger.event.data.forecast_hourly }}", + "var_forecast_twice_daily": "{{ trigger.event.data.forecast_twice_daily }}", + }, + }, + ], + "weather": [ + { + "name": "Test", + "condition_template": "sunny", + "precipitation_unit": "mm", + "pressure_unit": "hPa", + "visibility_unit": "km", + "wind_speed_unit": "km/h", + "temperature_unit": "°C", + "temperature_template": "{{ my_variable + 1 }}", + "humidity_template": "{{ my_variable + 1 }}", + "wind_speed_template": "{{ my_variable + 1 }}", + "wind_bearing_template": "{{ my_variable + 1 }}", + "ozone_template": "{{ my_variable + 1 }}", + "visibility_template": "{{ my_variable + 1 }}", + "pressure_template": "{{ my_variable + 1 }}", + "wind_gust_speed_template": "{{ my_variable + 1 }}", + "cloud_coverage_template": "{{ my_variable + 1 }}", + "dew_point_template": "{{ my_variable + 1 }}", + "apparent_temperature_template": "{{ my_variable + 1 }}", + "forecast_template": "{{ var_forecast_daily }}", + "forecast_daily_template": "{{ var_forecast_daily }}", + "forecast_hourly_template": "{{ var_forecast_hourly }}", + "forecast_twice_daily_template": "{{ var_forecast_twice_daily }}", + } + ], + }, + ], + }, + ], +) +async def test_trigger_weather_services( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger weather entity with services.""" + state = hass.states.get("weather.test") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + now = dt_util.now().isoformat() + hass.bus.async_fire( + "test_event", + { + "information": 1, + "forecast_daily": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + } + ], + "forecast_hourly": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + } + ], + "forecast_twice_daily": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + "is_daytime": True, + } + ], + }, + context=context, + ) + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == "sunny" + assert state.attributes["temperature"] == 3.0 + assert state.attributes["humidity"] == 3.0 + assert state.attributes["wind_speed"] == 3.0 + assert state.attributes["wind_bearing"] == 3.0 + assert state.attributes["ozone"] == 3.0 + assert state.attributes["visibility"] == 3.0 + assert state.attributes["pressure"] == 3.0 + assert state.attributes["wind_gust_speed"] == 3.0 + assert state.attributes["cloud_coverage"] == 3.0 + assert state.attributes["dew_point"] == 3.0 + assert state.attributes["apparent_temperature"] == 3.0 + assert state.context is context + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + } + ], + } + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + } + ], + } + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "twice_daily", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + "is_daytime": True, + } + ], + } + + +async def test_restore_weather_save_state( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test Restore saved state for Weather trigger template.""" + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.bus.async_fire( + "test_event", {"condition": "cloudy", "temperature": 15, "humidity": 25} + ) + await hass.async_block_till_done() + entity = hass.states.get("weather.test") + + # Trigger saving state + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == entity.entity_id + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_humidity": "25.0", + "last_ozone": None, + "last_pressure": None, + "last_temperature": "15.0", + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, + } + + +SAVED_ATTRIBUTES_1 = { + "humidity": 20, + "temperature": 10, +} + +SAVED_EXTRA_DATA_MISSING_KEY = { + "last_cloud_coverage": None, + "last_dew_point": None, + "last_humidity": 20, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, +} + + +@pytest.mark.parametrize( + ("saved_attributes", "saved_extra_data"), + [ + (SAVED_ATTRIBUTES_1, SAVED_EXTRA_DATA_MISSING_KEY), + (SAVED_ATTRIBUTES_1, None), + ], +) +async def test_trigger_entity_restore_state_fail( + hass: HomeAssistant, + saved_attributes: dict, + saved_extra_data: dict | None, +) -> None: + """Test restoring trigger template weather fails due to missing attribute.""" + + saved_state = State( + "weather.test", + None, + saved_attributes, + ) + mock_restore_cache_with_extra_data(hass, ((saved_state, saved_extra_data),)) + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("temperature") is None diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index eabc5e04e0bce5..6b6929e88eca16 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -319,7 +319,7 @@ async def test_start_service(hass: HomeAssistant) -> None: with pytest.raises( HomeAssistantError, - match="Not possible to change timer timer.test1 beyond configured duration", + match="Not possible to change timer timer.test1 beyond duration", ): await hass.services.async_call( DOMAIN, @@ -370,7 +370,7 @@ async def test_start_service(hass: HomeAssistant) -> None: state = hass.states.get("timer.test1") assert state assert state.state == STATUS_IDLE - assert state.attributes[ATTR_DURATION] == "0:00:15" + assert state.attributes[ATTR_DURATION] == "0:00:10" assert ATTR_REMAINING not in state.attributes with pytest.raises( @@ -387,7 +387,7 @@ async def test_start_service(hass: HomeAssistant) -> None: state = hass.states.get("timer.test1") assert state assert state.state == STATUS_IDLE - assert state.attributes[ATTR_DURATION] == "0:00:15" + assert state.attributes[ATTR_DURATION] == "0:00:10" assert ATTR_REMAINING not in state.attributes @@ -844,43 +844,6 @@ async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) - assert count_start == len(hass.states.async_entity_ids()) -async def test_restore_idle(hass: HomeAssistant) -> None: - """Test entity restore logic when timer is idle.""" - utc_now = utcnow() - stored_state = StoredState( - State( - "timer.test", - STATUS_IDLE, - {ATTR_DURATION: "0:00:30"}, - ), - None, - utc_now, - ) - - data = async_get(hass) - await data.store.async_save([stored_state.as_dict()]) - await data.async_load() - - entity = Timer.from_storage( - { - CONF_ID: "test", - CONF_NAME: "test", - CONF_DURATION: "0:01:00", - CONF_RESTORE: True, - } - ) - entity.hass = hass - entity.entity_id = "timer.test" - - await entity.async_added_to_hass() - await hass.async_block_till_done() - assert entity.state == STATUS_IDLE - assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30" - assert ATTR_REMAINING not in entity.extra_state_attributes - assert ATTR_FINISHES_AT not in entity.extra_state_attributes - assert entity.extra_state_attributes[ATTR_RESTORE] - - @pytest.mark.freeze_time("2023-06-05 17:47:50") async def test_restore_paused(hass: HomeAssistant) -> None: """Test entity restore logic when timer is paused.""" @@ -1007,7 +970,7 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non await hass.async_block_till_done() assert entity.state == STATUS_IDLE - assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30" + assert entity.extra_state_attributes[ATTR_DURATION] == "0:01:00" assert ATTR_REMAINING not in entity.extra_state_attributes assert ATTR_FINISHES_AT not in entity.extra_state_attributes assert entity.extra_state_attributes[ATTR_RESTORE] diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index c477b9a11fedc2..cccf1add61b7f3 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -2,16 +2,19 @@ from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant import config as hass_config, setup from homeassistant.components.trend.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State import homeassistant.util.dt as dt_util from tests.common import ( assert_setup_component, get_fixture_path, get_test_home_assistant, + mock_restore_cache, ) @@ -413,3 +416,28 @@ async def test_reload(hass: HomeAssistant) -> None: assert hass.states.get("binary_sensor.test_trend_sensor") is None assert hass.states.get("binary_sensor.second_test_trend_sensor") + + +@pytest.mark.parametrize( + ("saved_state", "restored_state"), + [("on", "on"), ("off", "off"), ("unknown", "unknown")], +) +async def test_restore_state( + hass: HomeAssistant, saved_state: str, restored_state: str +) -> None: + """Test we restore the trend state.""" + mock_restore_cache(hass, (State("binary_sensor.test_trend_sensor", saved_state),)) + + assert await setup.async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, + } + }, + ) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state diff --git a/tests/components/twinkly/__init__.py b/tests/components/twinkly/__init__.py index 0780bc0126f871..bd51ac5d7cde86 100644 --- a/tests/components/twinkly/__init__.py +++ b/tests/components/twinkly/__init__.py @@ -33,7 +33,6 @@ def __init__(self) -> None: "uuid": self.id, "device_name": TEST_NAME, "product_code": TEST_MODEL, - "sw_version": self.version, } @property diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index c5788444845593..cda2ad3d60ef5b 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -16,7 +16,6 @@ 'device_info': dict({ 'device_name': 'twinkly_test_device_name', 'product_code': 'twinkly_test_device_model', - 'sw_version': '2.8.10', 'uuid': '4c8fccf5-e08a-4173-92d5-49bf479252a2', }), 'entry': dict({ @@ -39,5 +38,6 @@ 'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', 'version': 1, }), + 'sw_version': '2.8.10', }) # --- diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py index bf35484f53e812..26746c7abb4196 100644 --- a/tests/components/twitch/__init__.py +++ b/tests/components/twitch/__init__.py @@ -1,10 +1,10 @@ """Tests for the Twitch component.""" import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, AsyncIterator from dataclasses import dataclass -from typing import Any +from datetime import datetime -from twitchAPI.object import TwitchUser +from twitchAPI.object.api import FollowedChannelsResult, TwitchUser from twitchAPI.twitch import ( InvalidTokenException, MissingScopeException, @@ -12,24 +12,34 @@ TwitchAuthorizationException, TwitchResourceNotFound, ) -from twitchAPI.types import AuthScope, AuthType - -USER_OBJECT: TwitchUser = TwitchUser( - id=123, - display_name="channel123", - offline_image_url="logo.png", - profile_image_url="logo.png", - view_count=42, -) +from twitchAPI.type import AuthScope, AuthType +from homeassistant.core import HomeAssistant -class TwitchUserFollowResultMock: - """Mock for twitch user follow result.""" +from tests.common import MockConfigEntry - def __init__(self, follows: list[dict[str, Any]]) -> None: - """Initialize mock.""" - self.total = len(follows) - self.data = follows + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + + +def _get_twitch_user(user_id: str = "123") -> TwitchUser: + return TwitchUser( + id=user_id, + display_name="channel123", + offline_image_url="logo.png", + profile_image_url="logo.png", + view_count=42, + ) + + +async def async_iterator(iterable) -> AsyncIterator: + """Return async iterator.""" + for i in iterable: + yield i @dataclass @@ -41,12 +51,20 @@ class UserSubscriptionMock: @dataclass -class UserFollowMock: - """User follow mock.""" +class FollowedChannelMock: + """Followed channel mock.""" + broadcaster_login: str followed_at: str +@dataclass +class ChannelFollowerMock: + """Channel follower mock.""" + + user_id: str + + @dataclass class StreamMock: """Stream mock.""" @@ -56,6 +74,32 @@ class StreamMock: thumbnail_url: str +class TwitchUserFollowResultMock: + """Mock for twitch user follow result.""" + + def __init__(self, follows: list[FollowedChannelMock]) -> None: + """Initialize mock.""" + self.total = len(follows) + self.data = follows + + def __aiter__(self): + """Return async iterator.""" + return async_iterator(self.data) + + +class ChannelFollowersResultMock: + """Mock for twitch channel follow result.""" + + def __init__(self, follows: list[ChannelFollowerMock]) -> None: + """Initialize mock.""" + self.total = len(follows) + self.data = follows + + def __aiter__(self): + """Return async iterator.""" + return async_iterator(self.data) + + STREAMS = StreamMock( game_name="Good game", title="Title", thumbnail_url="stream-medium.png" ) @@ -64,25 +108,18 @@ class StreamMock: class TwitchMock: """Mock for the twitch object.""" + is_streaming = True + is_gifted = False + is_subscribed = False + is_following = True + different_user_id = False + def __await__(self): """Add async capabilities to the mock.""" t = asyncio.create_task(self._noop()) yield from t return self - def __init__( - self, - is_streaming: bool = True, - is_gifted: bool = False, - is_subscribed: bool = False, - is_following: bool = True, - ) -> None: - """Initialize mock.""" - self._is_streaming = is_streaming - self._is_gifted = is_gifted - self._is_subscribed = is_subscribed - self._is_following = is_following - async def _noop(self): """Fake function to create task.""" pass @@ -91,7 +128,8 @@ async def get_users( self, user_ids: list[str] | None = None, logins: list[str] | None = None ) -> AsyncGenerator[TwitchUser, None]: """Get list of mock users.""" - for user in [USER_OBJECT]: + users = [_get_twitch_user("234" if self.different_user_id else "123")] + for user in users: yield user def has_required_auth( @@ -100,38 +138,56 @@ def has_required_auth( """Return if auth required.""" return True - async def get_users_follows( - self, to_id: str | None = None, from_id: str | None = None - ) -> TwitchUserFollowResultMock: - """Return the followers of the user.""" - if self._is_following: - return TwitchUserFollowResultMock( - follows=[UserFollowMock("2020-01-20T21:22:42") for _ in range(0, 24)] - ) - return TwitchUserFollowResultMock(follows=[]) - async def check_user_subscription( self, broadcaster_id: str, user_id: str ) -> UserSubscriptionMock: """Check if the user is subscribed.""" - if self._is_subscribed: + if self.is_subscribed: return UserSubscriptionMock( - broadcaster_id=broadcaster_id, is_gift=self._is_gifted + broadcaster_id=broadcaster_id, is_gift=self.is_gifted ) raise TwitchResourceNotFound async def set_user_authentication( - self, token: str, scope: list[AuthScope], validate: bool = True + self, + token: str, + scope: list[AuthScope], + validate: bool = True, ) -> None: """Set user authentication.""" pass + async def get_followed_channels( + self, user_id: str, broadcaster_id: str | None = None + ) -> FollowedChannelsResult: + """Get followed channels.""" + if self.is_following: + return TwitchUserFollowResultMock( + [ + FollowedChannelMock( + followed_at=datetime(year=2023, month=8, day=1), + broadcaster_login="internetofthings", + ), + FollowedChannelMock( + followed_at=datetime(year=2023, month=8, day=1), + broadcaster_login="homeassistant", + ), + ] + ) + return TwitchUserFollowResultMock([]) + + async def get_channel_followers( + self, broadcaster_id: str + ) -> ChannelFollowersResultMock: + """Get channel followers.""" + return ChannelFollowersResultMock([ChannelFollowerMock(user_id="abc")]) + async def get_streams( self, user_id: list[str], first: int ) -> AsyncGenerator[StreamMock, None]: """Get streams for the user.""" streams = [] - if self._is_streaming: + if self.is_streaming: streams = [STREAMS] for stream in streams: yield stream diff --git a/tests/components/twitch/conftest.py b/tests/components/twitch/conftest.py new file mode 100644 index 00000000000000..b3894203786161 --- /dev/null +++ b/tests/components/twitch/conftest.py @@ -0,0 +1,110 @@ +"""Configure tests for the Twitch integration.""" +from collections.abc import Awaitable, Callable, Generator +import time +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.twitch.const import DOMAIN, OAUTH2_TOKEN, OAUTH_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.twitch import TwitchMock +from tests.test_util.aiohttp import AiohttpClientMocker + +ComponentSetup = Callable[[TwitchMock | None], Awaitable[None]] + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +TITLE = "Test" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.twitch.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return [scope.value for scope in OAUTH_SCOPES] + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Twitch entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id="123", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + options={"channels": ["internetofthings"]}, + ) + + +@pytest.fixture(autouse=True) +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Twitch connection.""" + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@pytest.fixture(name="twitch_mock") +def twitch_mock() -> TwitchMock: + """Return as fixture to inject other mocks.""" + return TwitchMock() + + +@pytest.fixture(name="twitch") +def mock_twitch(twitch_mock: TwitchMock): + """Mock Twitch.""" + with patch( + "homeassistant.components.twitch.Twitch", + return_value=twitch_mock, + ), patch( + "homeassistant.components.twitch.config_flow.Twitch", + return_value=twitch_mock, + ): + yield twitch_mock diff --git a/tests/components/twitch/test_config_flow.py b/tests/components/twitch/test_config_flow.py new file mode 100644 index 00000000000000..36312fea83e993 --- /dev/null +++ b/tests/components/twitch/test_config_flow.py @@ -0,0 +1,295 @@ +"""Test config flow for Twitch.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.twitch.const import ( + CONF_CHANNELS, + DOMAIN, + OAUTH2_AUTHORIZE, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.twitch import TwitchInvalidTokenMock, TwitchMock +from tests.components.twitch.conftest import CLIENT_ID, TITLE +from tests.typing import ClientSessionGenerator + + +async def _do_get_token( + hass: HomeAssistant, + result: FlowResult, + hass_client_no_auth: ClientSessionGenerator, + scopes: list[str], +) -> None: + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(scopes)}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "twitch", context={"source": SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "channel123" + assert "result" in result + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["result"].unique_id == "123" + assert result["options"] == {CONF_CHANNELS: ["internetofthings", "homeassistant"]} + + +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check flow aborts when account already configured.""" + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + "twitch", context={"source": SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + with patch( + "homeassistant.components.twitch.config_flow.Twitch", return_value=TwitchMock() + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check reauth flow.""" + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_from_import( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, + expires_at, + scopes: list[str], +) -> None: + """Check reauth flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id="123", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + "imported": True, + }, + options={"channels": ["internetofthings"]}, + ) + await test_reauth( + hass, + hass_client_no_auth, + current_request_with_host, + config_entry, + mock_setup_entry, + twitch, + scopes, + ) + entries = hass.config_entries.async_entries(DOMAIN) + entry = entries[0] + assert "imported" not in entry.data + assert entry.options == {CONF_CHANNELS: ["internetofthings", "homeassistant"]} + + +async def test_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check reauth flow.""" + await setup_integration(hass, config_entry) + twitch.different_user_id = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +async def test_import( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, +) -> None: + """Test import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + }, + data={ + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "channel123" + assert "result" in result + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "efgh" + assert result["result"].data["token"]["refresh_token"] == "" + assert result["result"].unique_id == "123" + assert result["options"] == {CONF_CHANNELS: ["channel123"]} + + +@pytest.mark.parametrize("twitch_mock", [TwitchInvalidTokenMock()]) +async def test_import_invalid_token( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, +) -> None: + """Test import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + }, + data={ + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + }, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "invalid_token" + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_import_already_imported( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, +) -> None: + """Test import flow where the config is already imported.""" + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + }, + data={ + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + }, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 diff --git a/tests/components/twitch/test_init.py b/tests/components/twitch/test_init.py new file mode 100644 index 00000000000000..da03857a95ddb7 --- /dev/null +++ b/tests/components/twitch/test_init.py @@ -0,0 +1,116 @@ +"""Tests for YouTube.""" +import http +import time +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientError +import pytest + +from homeassistant.components.twitch.const import DOMAIN, OAUTH2_TOKEN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import TwitchMock, setup_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup_success( + hass: HomeAssistant, config_entry: MockConfigEntry, twitch: TwitchMock +) -> None: + """Test successful setup and unload.""" + await setup_integration(hass, config_entry) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert not hass.services.async_services().get(DOMAIN) + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + twitch: TwitchMock, +) -> None: + """Test expired token is refreshed.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration(hass, config_entry) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert entries[0].data["token"]["access_token"] == "updated-access-token" + assert entries[0].data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, + config_entry: MockConfigEntry, + twitch: TwitchMock, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + status=status, + ) + + await setup_integration(hass, config_entry) + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_expired_token_refresh_client_error( + hass: HomeAssistant, config_entry: MockConfigEntry, twitch: TwitchMock +) -> None: + """Test failure while refreshing token with a client error.""" + + with patch( + "homeassistant.components.twitch.OAuth2Session.async_ensure_token_valid", + side_effect=ClientError, + ): + await setup_integration(hass, config_entry) + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/twitch/test_sensor.py b/tests/components/twitch/test_sensor.py new file mode 100644 index 00000000000000..047c55d3b7298e --- /dev/null +++ b/tests/components/twitch/test_sensor.py @@ -0,0 +1,177 @@ +"""The tests for an update of the Twitch component.""" +from datetime import datetime + +import pytest + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.twitch.const import CONF_CHANNELS, DOMAIN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from ...common import MockConfigEntry +from . import ( + TwitchAPIExceptionMock, + TwitchInvalidTokenMock, + TwitchInvalidUserMock, + TwitchMissingScopeMock, + TwitchMock, + TwitchUnauthorizedMock, + setup_integration, +) + +ENTITY_ID = "sensor.channel123" +CONFIG = { + "auth_implementation": "cred", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", +} + +LEGACY_CONFIG_WITHOUT_TOKEN = { + SENSOR_DOMAIN: { + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + "channels": ["channel123"], + } +} + +LEGACY_CONFIG = { + SENSOR_DOMAIN: { + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + } +} + +OPTIONS = {CONF_CHANNELS: ["channel123"]} + + +async def test_legacy_migration( + hass: HomeAssistant, twitch: TwitchMock, mock_setup_entry +) -> None: + """Test importing legacy yaml.""" + assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_legacy_migration_without_token( + hass: HomeAssistant, twitch: TwitchMock +) -> None: + """Test importing legacy yaml.""" + assert await async_setup_component( + hass, Platform.SENSOR, LEGACY_CONFIG_WITHOUT_TOKEN + ) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 0 + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_offline( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test offline state.""" + twitch.is_streaming = False + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "offline" + assert sensor_state.attributes["entity_picture"] == "logo.png" + + +async def test_streaming( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test streaming state.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "streaming" + assert sensor_state.attributes["entity_picture"] == "stream-medium.png" + assert sensor_state.attributes["game"] == "Good game" + assert sensor_state.attributes["title"] == "Title" + + +async def test_oauth_without_sub_and_follow( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test state with oauth.""" + twitch.is_following = False + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is False + assert sensor_state.attributes["following"] is False + + +async def test_oauth_with_sub( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test state with oauth and sub.""" + twitch.is_subscribed = True + twitch.is_following = False + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is True + assert sensor_state.attributes["subscription_is_gifted"] is False + assert sensor_state.attributes["following"] is False + + +async def test_oauth_with_follow( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test state with oauth and follow.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["following"] is True + assert sensor_state.attributes["following_since"] == datetime( + year=2023, month=8, day=1 + ) + + +@pytest.mark.parametrize( + "twitch_mock", + [TwitchUnauthorizedMock(), TwitchMissingScopeMock(), TwitchInvalidTokenMock()], +) +async def test_auth_invalid( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test auth failures.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state is None + + +@pytest.mark.parametrize("twitch_mock", [TwitchInvalidUserMock()]) +async def test_auth_with_invalid_user( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test auth with invalid user.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert "subscribed" not in sensor_state.attributes + + +@pytest.mark.parametrize("twitch_mock", [TwitchAPIExceptionMock()]) +async def test_auth_with_api_exception( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test auth with invalid user.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is False + assert "subscription_is_gifted" not in sensor_state.attributes diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py deleted file mode 100644 index 4a33831dd32ed0..00000000000000 --- a/tests/components/twitch/test_twitch.py +++ /dev/null @@ -1,205 +0,0 @@ -"""The tests for an update of the Twitch component.""" -from unittest.mock import patch - -from homeassistant.components import sensor -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from . import ( - TwitchAPIExceptionMock, - TwitchInvalidTokenMock, - TwitchInvalidUserMock, - TwitchMissingScopeMock, - TwitchMock, - TwitchUnauthorizedMock, -) - -ENTITY_ID = "sensor.channel123" -CONFIG = { - sensor.DOMAIN: { - "platform": "twitch", - CONF_CLIENT_ID: "1234", - CONF_CLIENT_SECRET: " abcd", - "channels": ["channel123"], - } -} -CONFIG_WITH_OAUTH = { - sensor.DOMAIN: { - "platform": "twitch", - CONF_CLIENT_ID: "1234", - CONF_CLIENT_SECRET: "abcd", - "channels": ["channel123"], - "token": "9876", - } -} - - -async def test_init(hass: HomeAssistant) -> None: - """Test initial config.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(is_streaming=False), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.state == "offline" - assert sensor_state.name == "channel123" - assert sensor_state.attributes["icon"] == "mdi:twitch" - assert sensor_state.attributes["friendly_name"] == "channel123" - assert sensor_state.attributes["views"] == 42 - assert sensor_state.attributes["followers"] == 24 - - -async def test_offline(hass: HomeAssistant) -> None: - """Test offline state.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(is_streaming=False), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.state == "offline" - assert sensor_state.attributes["entity_picture"] == "logo.png" - - -async def test_streaming(hass: HomeAssistant) -> None: - """Test streaming state.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.state == "streaming" - assert sensor_state.attributes["entity_picture"] == "stream-medium.png" - assert sensor_state.attributes["game"] == "Good game" - assert sensor_state.attributes["title"] == "Title" - - -async def test_oauth_without_sub_and_follow(hass: HomeAssistant) -> None: - """Test state with oauth.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(is_following=False), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["subscribed"] is False - assert sensor_state.attributes["following"] is False - - -async def test_oauth_with_sub(hass: HomeAssistant) -> None: - """Test state with oauth and sub.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock( - is_subscribed=True, is_gifted=False, is_following=False - ), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["subscribed"] is True - assert sensor_state.attributes["subscription_is_gifted"] is False - assert sensor_state.attributes["following"] is False - - -async def test_oauth_with_follow(hass: HomeAssistant) -> None: - """Test state with oauth and follow.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["following"] is True - assert sensor_state.attributes["following_since"] == "2020-01-20T21:22:42" - - -async def test_auth_with_invalid_credentials(hass: HomeAssistant) -> None: - """Test auth with invalid credentials.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchUnauthorizedMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state is None - - -async def test_auth_with_missing_scope(hass: HomeAssistant) -> None: - """Test auth with invalid credentials.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMissingScopeMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state is None - - -async def test_auth_with_invalid_token(hass: HomeAssistant) -> None: - """Test auth with invalid credentials.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchInvalidTokenMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state is None - - -async def test_auth_with_invalid_user(hass: HomeAssistant) -> None: - """Test auth with invalid user.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchInvalidUserMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert "subscribed" not in sensor_state.attributes - - -async def test_auth_with_api_exception(hass: HomeAssistant) -> None: - """Test auth with invalid user.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchAPIExceptionMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["subscribed"] is False - assert "subscription_is_gifted" not in sensor_state.attributes diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index ca0c855d1ab598..d48ff6139026c6 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -1,47 +1,100 @@ """Fixtures for UniFi Network methods.""" from __future__ import annotations +import asyncio +from datetime import timedelta from unittest.mock import patch from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketSignal, WebsocketState import pytest +from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN +from homeassistant.components.unifi.controller import RETRY_TIMER +from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.unifi.test_controller import DEFAULT_CONFIG_ENTRY_ID +from tests.test_util.aiohttp import AiohttpClientMocker + + +class WebsocketStateManager(asyncio.Event): + """Keep an async event that simules websocket context manager. + + Prepares disconnect and reconnect flows. + """ + + def __init__(self, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Store hass object and initialize asyncio.Event.""" + self.hass = hass + self.aioclient_mock = aioclient_mock + super().__init__() + + async def disconnect(self): + """Mark future as done to make 'await self.api.start_websocket' return.""" + self.set() + await self.hass.async_block_till_done() + + async def reconnect(self, fail=False): + """Set up new future to make 'await self.api.start_websocket' block. + + Mock api calls done by 'await self.api.login'. + Fail will make 'await self.api.start_websocket' return immediately. + """ + controller = self.hass.data[UNIFI_DOMAIN][DEFAULT_CONFIG_ENTRY_ID] + self.aioclient_mock.get( + f"https://{controller.host}:1234", status=302 + ) # Check UniFi OS + self.aioclient_mock.post( + f"https://{controller.host}:1234/api/login", + json={"data": "login successful", "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + if not fail: + self.clear() + new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) + async_fire_time_changed(self.hass, new_time) + await self.hass.async_block_till_done() + + +@pytest.fixture(autouse=True) +def websocket_mock(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Mock 'await self.api.start_websocket' in 'UniFiController.start_websocket'.""" + websocket_state_manager = WebsocketStateManager(hass, aioclient_mock) + with patch("aiounifi.Controller.start_websocket") as ws_mock: + ws_mock.side_effect = websocket_state_manager.wait + yield websocket_state_manager @pytest.fixture(autouse=True) -def mock_unifi_websocket(): +def mock_unifi_websocket(hass): """No real websocket allowed.""" - with patch("aiounifi.controller.WSClient") as mock: - - def make_websocket_call( - *, - message: MessageKey | None = None, - data: list[dict] | dict | None = None, - state: WebsocketState | None = None, - ): - """Generate a websocket call.""" - if data and not message: - mock.return_value.data = data - mock.call_args[1]["callback"](WebsocketSignal.DATA) - elif data and message: - if not isinstance(data, list): - data = [data] - mock.return_value.data = { + + def make_websocket_call( + *, + message: MessageKey | None = None, + data: list[dict] | dict | None = None, + ): + """Generate a websocket call.""" + controller = hass.data[UNIFI_DOMAIN][DEFAULT_CONFIG_ENTRY_ID] + if data and not message: + controller.api.messages.handler(data) + elif data and message: + if not isinstance(data, list): + data = [data] + controller.api.messages.handler( + { "meta": {"message": message.value}, "data": data, } - mock.call_args[1]["callback"](WebsocketSignal.DATA) - elif state: - mock.return_value.state = state - mock.call_args[1]["callback"](WebsocketSignal.CONNECTION_STATE) - else: - raise NotImplementedError - - yield make_websocket_call + ) + else: + raise NotImplementedError + + return make_websocket_call @pytest.fixture(autouse=True) diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 0c6ac38739e80d..30a1b3e08ffe1b 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -1,7 +1,5 @@ """UniFi Network button platform tests.""" -from aiounifi.websocket import WebsocketState - from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory @@ -14,7 +12,7 @@ async def test_restart_device_button( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock ) -> None: """Test restarting device button.""" config_entry = await setup_unifi_integration( @@ -71,11 +69,9 @@ async def test_restart_device_button( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("button.switch_restart").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("button.switch_restart").state != STATE_UNAVAILABLE diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index f4738862aef3f5..93b39d2fdf2b9a 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -6,7 +6,6 @@ from unittest.mock import Mock, patch import aiounifi -from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN @@ -28,7 +27,7 @@ PLATFORMS, UNIFI_WIRELESS_CLIENTS, ) -from homeassistant.components.unifi.controller import RETRY_TIMER, get_unifi_controller +from homeassistant.components.unifi.controller import get_unifi_controller from homeassistant.components.unifi.errors import AuthenticationRequired, CannotConnect from homeassistant.const import ( CONF_HOST, @@ -44,7 +43,7 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker DEFAULT_CONFIG_ENTRY_ID = "1" @@ -365,8 +364,8 @@ async def test_reset_fails( async def test_connection_state_signalling( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, mock_device_registry, + websocket_mock, ) -> None: """Verify connection statesignalling and connection state are working.""" client = { @@ -381,21 +380,17 @@ async def test_connection_state_signalling( # Controller is connected assert hass.states.get("device_tracker.client").state == "home" - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() # Controller is disconnected assert hass.states.get("device_tracker.client").state == "unavailable" - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - + await websocket_mock.reconnect() # Controller is once again connected assert hass.states.get("device_tracker.client").state == "home" async def test_reconnect_mechanism( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock ) -> None: """Verify reconnect prints only on first reconnection try.""" await setup_unifi_integration(hass, aioclient_mock) @@ -403,21 +398,13 @@ async def test_reconnect_mechanism( aioclient_mock.clear_requests() aioclient_mock.get(f"https://{DEFAULT_HOST}:1234/", status=HTTPStatus.BAD_GATEWAY) - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() assert aioclient_mock.call_count == 0 - new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() - + await websocket_mock.reconnect(fail=True) assert aioclient_mock.call_count == 1 - new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() - + await websocket_mock.reconnect(fail=True) assert aioclient_mock.call_count == 2 @@ -431,10 +418,7 @@ async def test_reconnect_mechanism( ], ) async def test_reconnect_mechanism_exceptions( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - exception, + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock, exception ) -> None: """Verify async_reconnect calls expected methods.""" await setup_unifi_integration(hass, aioclient_mock) @@ -442,11 +426,9 @@ async def test_reconnect_mechanism_exceptions( with patch("aiounifi.Controller.login", side_effect=exception), patch( "homeassistant.components.unifi.controller.UniFiController.reconnect" ) as mock_reconnect: - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() - new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) - async_fire_time_changed(hass, new_time) + await websocket_mock.reconnect() mock_reconnect.assert_called_once() diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 99874b3a949f15..2680a357d77af2 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -3,7 +3,6 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState from freezegun.api import FrozenDateTimeFactory from homeassistant import config_entries @@ -40,8 +39,8 @@ async def test_no_entities( async def test_tracked_wireless_clients( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, mock_device_registry, + mock_unifi_websocket, ) -> None: """Verify tracking of wireless clients.""" client = { @@ -402,7 +401,7 @@ async def test_remove_clients( async def test_controller_state_change( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, + websocket_mock, mock_device_registry, ) -> None: """Verify entities state reflect on controller becoming unavailable.""" @@ -443,16 +442,12 @@ async def test_controller_state_change( assert hass.states.get("device_tracker.device").state == STATE_HOME # Controller unavailable - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() assert hass.states.get("device_tracker.client").state == STATE_UNAVAILABLE assert hass.states.get("device_tracker.device").state == STATE_UNAVAILABLE # Controller available - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - + await websocket_mock.reconnect() assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME assert hass.states.get("device_tracker.device").state == STATE_HOME diff --git a/tests/components/unifi/test_image.py b/tests/components/unifi/test_image.py index 38a8cef43c1328..92879f5ad14ad9 100644 --- a/tests/components/unifi/test_image.py +++ b/tests/components/unifi/test_image.py @@ -5,7 +5,6 @@ from http import HTTPStatus from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState from syrupy.assertion import SnapshotAssertion from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN @@ -65,6 +64,7 @@ async def test_wlan_qr_code( hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, mock_unifi_websocket, + websocket_mock, ) -> None: """Test the update_clients function when no clients are found.""" await setup_unifi_integration(hass, aioclient_mock, wlans_response=[WLAN]) @@ -121,13 +121,11 @@ async def test_wlan_qr_code( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("image.ssid_1_qr_code").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("image.ssid_1_qr_code").state != STATE_UNAVAILABLE # WLAN gets disabled diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 7b6a3bc1edc6a7..b652c38abdb3d9 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -4,7 +4,6 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -562,7 +561,10 @@ async def test_remove_sensors( async def test_poe_port_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test the update_items function with some clients.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) @@ -607,16 +609,16 @@ async def test_poe_port_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert ( hass.states.get("sensor.mock_name_port_1_poe_power").state == STATE_UNAVAILABLE ) # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - assert hass.states.get("sensor.mock_name_port_1_poe_power") + await websocket_mock.reconnect() + assert ( + hass.states.get("sensor.mock_name_port_1_poe_power").state != STATE_UNAVAILABLE + ) # Device gets disabled device_1["disabled"] = True @@ -634,7 +636,10 @@ async def test_poe_port_switches( async def test_wlan_client_sensors( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Verify that WLAN client sensors are working as expected.""" wireless_client_1 = { @@ -720,13 +725,11 @@ async def test_wlan_client_sensors( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("sensor.ssid_1").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("sensor.ssid_1").state == "0" # WLAN gets disabled @@ -837,7 +840,6 @@ async def test_device_uptime( now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" @@ -854,7 +856,6 @@ async def test_device_uptime( now = datetime(2021, 1, 1, 1, 1, 4, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): mock_unifi_websocket(message=MessageKey.DEVICE, data=device) - await hass.async_block_till_done() assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" @@ -865,7 +866,6 @@ async def test_device_uptime( now = datetime(2021, 2, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): mock_unifi_websocket(message=MessageKey.DEVICE, data=device) - await hass.async_block_till_done() assert hass.states.get("sensor.device_uptime").state == "2021-02-01T01:00:00+00:00" @@ -908,5 +908,4 @@ async def test_device_temperature( # Verify new event change temperature device["general_temperature"] = 60 mock_unifi_websocket(message=MessageKey.DEVICE, data=device) - await hass.async_block_till_done() assert hass.states.get("sensor.device_temperature").state == "60" diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 8e53611929115d..a08cf0be688128 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -3,7 +3,6 @@ from datetime import timedelta from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.switch import ( @@ -1001,7 +1000,10 @@ async def test_block_switches( async def test_dpi_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test the update_items function with some clients.""" await setup_unifi_integration( @@ -1026,13 +1028,11 @@ async def test_dpi_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.block_media_streaming").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.block_media_streaming").state == STATE_OFF # Remove app @@ -1128,6 +1128,7 @@ async def test_outlet_switches( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket, + websocket_mock, entity_id: str, test_data: any, outlet_index: int, @@ -1192,13 +1193,11 @@ async def test_outlet_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Device gets disabled @@ -1320,7 +1319,10 @@ async def test_option_remove_switches( async def test_poe_port_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test the update_items function with some clients.""" config_entry = await setup_unifi_integration( @@ -1408,13 +1410,11 @@ async def test_poe_port_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF # Device gets disabled @@ -1431,7 +1431,10 @@ async def test_poe_port_switches( async def test_wlan_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test control of UniFi WLAN availability.""" config_entry = await setup_unifi_integration( @@ -1488,18 +1491,19 @@ async def test_wlan_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.ssid_1").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.ssid_1").state == STATE_OFF async def test_port_forwarding_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test control of UniFi port forwarding.""" _data = { @@ -1570,13 +1574,11 @@ async def test_port_forwarding_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.unifi_network_plex").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.unifi_network_plex").state == STATE_OFF # Remove entity on deleted message diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index e59eca371d6989..4f7a3dfe11d8cb 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -2,7 +2,6 @@ from copy import deepcopy from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState from yarl import URL from homeassistant.components.unifi.const import CONF_SITE_ID @@ -185,26 +184,18 @@ async def test_install( async def test_controller_state_change( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock ) -> None: """Verify entities state reflect on controller becoming unavailable.""" - await setup_unifi_integration( - hass, - aioclient_mock, - devices_response=[DEVICE_1], - ) + await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 1 assert hass.states.get("update.device_1").state == STATE_ON # Controller unavailable - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() assert hass.states.get("update.device_1").state == STATE_UNAVAILABLE # Controller available - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - + await websocket_mock.reconnect() assert hass.states.get("update.device_1").state == STATE_ON diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 10dfdd2ba14078..b2ae7b53cf5fa6 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -100,6 +100,7 @@ }), 'device_image': 'https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png', 'device_name': 'Humidifier', + 'device_region': 'US', 'device_status': 'off', 'device_type': 'LUH-A602S-WUS', 'enabled': False, @@ -128,6 +129,7 @@ ]), 'mode': None, 'night_light': True, + 'pid': None, 'speed': None, 'sub_device_no': None, 'type': 'wifi-air', @@ -174,6 +176,7 @@ }), 'device_image': '', 'device_name': 'Fan', + 'device_region': 'US', 'device_status': 'unknown', 'device_type': 'LV-PUR131S', 'extension': None, @@ -264,6 +267,7 @@ 'mac_id': '**REDACTED**', 'manager': '**REDACTED**', 'mode': None, + 'pid': None, 'speed': None, 'sub_device_no': None, 'type': 'wifi-air', diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 3d2ef0cf568621..41efd8af00cc1e 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -52,6 +52,8 @@ async def test_user(hass: HomeAssistant) -> None: [ (aiovodafone_exceptions.CannotConnect, "cannot_connect"), (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), + (aiovodafone_exceptions.AlreadyLogged, "already_logged"), + (aiovodafone_exceptions.ModelNotSupported, "model_not_supported"), (ConnectionResetError, "unknown"), ], ) @@ -152,6 +154,7 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: [ (aiovodafone_exceptions.CannotConnect, "cannot_connect"), (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), + (aiovodafone_exceptions.AlreadyLogged, "already_logged"), (ConnectionResetError, "unknown"), ], ) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 361e4e7f0e212b..f82a00087c6dcc 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -21,7 +21,7 @@ async def test_pipeline( """Test that pipeline function is called from RTP protocol.""" assert await async_setup_component(hass, "voip", {}) - def is_speech(self, chunk, sample_rate): + def is_speech(self, chunk): """Anything non-zero is speech.""" return sum(chunk) > 0 @@ -76,7 +76,7 @@ async def async_get_media_source_audio( return ("mp3", b"") with patch( - "webrtcvad.Vad.is_speech", + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", new=is_speech, ), patch( "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", @@ -210,7 +210,7 @@ async def test_tts_timeout( """Test that TTS will time out based on its length.""" assert await async_setup_component(hass, "voip", {}) - def is_speech(self, chunk, sample_rate): + def is_speech(self, chunk): """Anything non-zero is speech.""" return sum(chunk) > 0 @@ -269,7 +269,7 @@ async def async_get_media_source_audio( return ("raw", bytes(0)) with patch( - "webrtcvad.Vad.is_speech", + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", new=is_speech, ), patch( "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", diff --git a/tests/components/wake_word/snapshots/test_init.ambr b/tests/components/wake_word/snapshots/test_init.ambr deleted file mode 100644 index cf7c09cd730f18..00000000000000 --- a/tests/components/wake_word/snapshots/test_init.ambr +++ /dev/null @@ -1,14 +0,0 @@ -# serializer version: 1 -# name: test_detected_entity - None -# --- -# name: test_ws_detect - dict({ - 'event': dict({ - 'timestamp': 2048.0, - 'ww_id': 'test_ww', - }), - 'id': 1, - 'type': 'event', - }) -# --- diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index d37cb3aa5402b6..5d1cc5a4b3f126 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -2,8 +2,8 @@ from collections.abc import AsyncIterable, Generator from pathlib import Path +from freezegun import freeze_time import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow @@ -22,6 +22,7 @@ mock_platform, mock_restore_cache, ) +from tests.typing import WebSocketGenerator TEST_DOMAIN = "test" @@ -39,16 +40,22 @@ class MockProviderEntity(wake_word.WakeWordDetectionEntity): @property def supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" - return [wake_word.WakeWord(ww_id="test_ww", name="Test Wake Word")] + return [ + wake_word.WakeWord(id="test_ww", name="Test Wake Word"), + wake_word.WakeWord(id="test_ww_2", name="Test Wake Word 2"), + ] async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" + if wake_word_id is None: + wake_word_id = self.supported_wake_words[0].id + async for _chunk, timestamp in stream: if timestamp >= 2000: return wake_word.DetectionResult( - ww_id=self.supported_wake_words[0].ww_id, timestamp=timestamp + wake_word_id=wake_word_id, timestamp=timestamp ) # Not detected @@ -148,11 +155,20 @@ async def test_config_entry_unload( assert config_entry.state == ConfigEntryState.NOT_LOADED +@freeze_time("2023-06-22 10:30:00+00:00") +@pytest.mark.parametrize( + ("wake_word_id", "expected_ww"), + [ + (None, "test_ww"), + ("test_ww_2", "test_ww_2"), + ], +) async def test_detected_entity( hass: HomeAssistant, tmp_path: Path, setup: MockProviderEntity, - snapshot: SnapshotAssertion, + wake_word_id: str | None, + expected_ww: str, ) -> None: """Test successful detection through entity.""" @@ -164,11 +180,12 @@ async def three_second_stream(): # Need 2 seconds to trigger state = setup.state - result = await setup.async_process_audio_stream(three_second_stream()) - assert result == wake_word.DetectionResult("test_ww", 2048) + assert state is None + result = await setup.async_process_audio_stream(three_second_stream(), wake_word_id) + assert result == wake_word.DetectionResult(expected_ww, 2048) assert state != setup.state - assert state == snapshot + assert setup.state == "2023-06-22T10:30:00+00:00" async def test_not_detected_entity( @@ -184,7 +201,7 @@ async def one_second_stream(): # Need 2 seconds to trigger state = setup.state - result = await setup.async_process_audio_stream(one_second_stream()) + result = await setup.async_process_audio_stream(one_second_stream(), None) assert result is None # State should only change when there's a detection @@ -192,20 +209,20 @@ async def one_second_stream(): async def test_default_engine_none(hass: HomeAssistant, tmp_path: Path) -> None: - """Test async_default_engine.""" + """Test async_default_entity.""" assert await async_setup_component(hass, wake_word.DOMAIN, {wake_word.DOMAIN: {}}) await hass.async_block_till_done() - assert wake_word.async_default_engine(hass) is None + assert wake_word.async_default_entity(hass) is None async def test_default_engine_entity( hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity ) -> None: - """Test async_default_engine.""" + """Test async_default_entity.""" await mock_config_entry_setup(hass, tmp_path, mock_provider_entity) - assert wake_word.async_default_engine(hass) == f"{wake_word.DOMAIN}.{TEST_DOMAIN}" + assert wake_word.async_default_entity(hass) == f"{wake_word.DOMAIN}.{TEST_DOMAIN}" async def test_get_engine_entity( @@ -244,3 +261,50 @@ async def test_entity_attributes( ) -> None: """Test that the provider entity attributes match expectations.""" assert mock_provider_entity.entity_category == EntityCategory.DIAGNOSTIC + + +async def test_list_wake_words( + hass: HomeAssistant, + setup: MockProviderEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the list_wake_words websocket command works.""" + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "wake_word/info", + "entity_id": setup.entity_id, + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == { + "wake_words": [ + {"id": "test_ww", "name": "Test Wake Word"}, + {"id": "test_ww_2", "name": "Test Wake Word 2"}, + ] + } + + +async def test_list_wake_words_unknown_entity( + hass: HomeAssistant, + setup: MockProviderEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the list_wake_words websocket command works.""" + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "wake_word/info", + "entity_id": "wake_word.blah", + } + ) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"] == {"code": "not_found", "message": "Entity not found"} diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index 1f052643696ca6..477fb10d292e86 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -5,9 +5,9 @@ ERROR = "error" STATUS = "status" -MOCK_NUMBER_ENTITY_ID = "number.mock_title_max_charging_current" -MOCK_LOCK_ENTITY_ID = "lock.mock_title_locked_unlocked" -MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.mock_title_charging_speed" -MOCK_SENSOR_CHARGING_POWER_ID = "sensor.mock_title_charging_power" -MOCK_SENSOR_MAX_AVAILABLE_POWER = "sensor.mock_title_max_available_power" -MOCK_SWITCH_ENTITY_ID = "switch.mock_title_pause_resume" +MOCK_NUMBER_ENTITY_ID = "number.wallbox_wallboxname_maximum_charging_current" +MOCK_LOCK_ENTITY_ID = "lock.wallbox_wallboxname_lock" +MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.wallbox_wallboxname_charging_speed" +MOCK_SENSOR_CHARGING_POWER_ID = "sensor.wallbox_wallboxname_charging_power" +MOCK_SENSOR_MAX_AVAILABLE_POWER = "sensor.wallbox_wallboxname_max_available_power" +MOCK_SWITCH_ENTITY_ID = "switch.wallbox_wallboxname_pause_resume" diff --git a/tests/components/wallbox/test_sensor.py b/tests/components/wallbox/test_sensor.py index a6bda688997138..ca12e1d9ac3e4a 100644 --- a/tests/components/wallbox/test_sensor.py +++ b/tests/components/wallbox/test_sensor.py @@ -21,11 +21,11 @@ async def test_wallbox_sensor_class( state = hass.states.get(MOCK_SENSOR_CHARGING_POWER_ID) assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == UnitOfPower.KILO_WATT - assert state.name == "Mock Title Charging Power" + assert state.name == "Wallbox WallboxName Charging power" state = hass.states.get(MOCK_SENSOR_CHARGING_SPEED_ID) assert state.attributes[CONF_ICON] == "mdi:speedometer" - assert state.name == "Mock Title Charging Speed" + assert state.name == "Wallbox WallboxName Charging speed" # Test round with precision '0' works state = hass.states.get(MOCK_SENSOR_MAX_AVAILABLE_POWER) diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index 3901ffad550eea..7a95e000d82a58 100644 --- a/tests/components/waqi/test_config_flow.py +++ b/tests/components/waqi/test_config_flow.py @@ -1,17 +1,20 @@ """Test the World Air Quality Index (WAQI) config flow.""" import json +from typing import Any from unittest.mock import AsyncMock, patch from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError import pytest from homeassistant import config_entries +from homeassistant.components.waqi.config_flow import CONF_MAP from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, + CONF_METHOD, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -21,27 +24,68 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +@pytest.mark.parametrize( + ("method", "payload"), + [ + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + ), + ], +) +async def test_full_map_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + method: str, + payload: dict[str, Any], +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_ip", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == method + with patch( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ), patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - CONF_API_KEY: "asd", - }, + payload, ) await hass.async_block_till_done() @@ -73,26 +117,40 @@ async def test_flow_errors( with patch( "aiowaqi.WAQIClient.authenticate", ), patch( - "aiowaqi.WAQIClient.get_by_coordinates", + "aiowaqi.WAQIClient.get_by_ip", side_effect=exception, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - CONF_API_KEY: "asd", - }, + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, ) await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": error} + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_ip", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "map" + with patch( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -100,9 +158,118 @@ async def test_flow_errors( result["flow_id"], { CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - CONF_API_KEY: "asd", }, ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("method", "payload", "exception", "error"), + [ + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + WAQIConnectionError(), + "cannot_connect", + ), + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + Exception(), + "unknown", + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + WAQIConnectionError(), + "cannot_connect", + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + Exception(), + "unknown", + ), + ], +) +async def test_error_in_second_step( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + method: str, + payload: dict[str, Any], + exception: Exception, + error: str, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_ip", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == method + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception + ), patch("aiowaqi.WAQIClient.get_by_station_number", side_effect=exception): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ), patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "de Jongweg, Utrecht" + assert result["data"] == { + CONF_API_KEY: "asd", + CONF_STATION_NUMBER: 4584, + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 18f77028a29d18..7feb37a1b09c84 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -4,6 +4,7 @@ from aiowaqi import WAQIAirQuality, WAQIError, WAQISearchResult +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN from homeassistant.components.waqi.sensor import CONF_LOCATIONS, CONF_STATIONS from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState @@ -15,7 +16,7 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture @@ -36,7 +37,7 @@ async def test_legacy_migration(hass: HomeAssistant) -> None: """Test migration from yaml to config flow.""" search_result_json = json.loads(load_fixture("waqi/search_result.json")) search_results = [ - WAQISearchResult.parse_obj(search_result) + WAQISearchResult.from_dict(search_result) for search_result in search_result_json ] with patch( @@ -44,7 +45,7 @@ async def test_legacy_migration(hass: HomeAssistant) -> None: return_value=search_results, ), patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -64,7 +65,7 @@ async def test_legacy_migration_already_imported( mock_config_entry.add_to_hass(hass) with patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -93,12 +94,38 @@ async def test_legacy_migration_already_imported( assert len(issue_registry.issues) == 1 +async def test_sensor_id_migration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test migrating unique id for original sensor.""" + mock_config_entry.add_to_hass(hass) + entity_registry = er.async_get(hass) + entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, 4584, config_entry=mock_config_entry + ) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == 1 + assert hass.states.get("sensor.waqi_4584") + assert hass.states.get("sensor.waqi_de_jongweg_utrecht") is None + assert entities[0].unique_id == "4584_air_quality" + + async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: """Test failed update.""" mock_config_entry.add_to_hass(hass) with patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): diff --git a/tests/components/weatherflow/__init__.py b/tests/components/weatherflow/__init__.py new file mode 100644 index 00000000000000..e7dd3dc0958c8e --- /dev/null +++ b/tests/components/weatherflow/__init__.py @@ -0,0 +1 @@ +"""Tests for the WeatherFlow integration.""" diff --git a/tests/components/weatherflow/conftest.py b/tests/components/weatherflow/conftest.py new file mode 100644 index 00000000000000..0bf6b69b9a7597 --- /dev/null +++ b/tests/components/weatherflow/conftest.py @@ -0,0 +1,79 @@ +"""Fixtures for Weatherflow integration tests.""" +import asyncio +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED +from pyweatherflowudp.device import WeatherFlowDevice + +from homeassistant.components.weatherflow.const import DOMAIN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.weatherflow.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry(domain=DOMAIN, data={}) + + +@pytest.fixture +def mock_has_devices() -> Generator[AsyncMock, None, None]: + """Return a mock has_devices function.""" + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.on", + return_value=True, + ) as mock_has_devices: + yield mock_has_devices + + +@pytest.fixture +def mock_stop() -> Generator[AsyncMock, None, None]: + """Return a fixture to handle the stop of udp.""" + + async def mock_stop_listening(self): + self._udp_task.cancel() + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.stop_listening", + autospec=True, + side_effect=mock_stop_listening, + ) as mock_function: + yield mock_function + + +@pytest.fixture +def mock_start() -> Generator[AsyncMock, None, None]: + """Return fixture for starting upd.""" + + device = WeatherFlowDevice( + serial_number="HB-00000001", + data=load_json_object_fixture("weatherflow/device.json"), + ) + + async def device_discovery_task(self): + await asyncio.gather( + await asyncio.sleep(0.1), self.emit(EVENT_DEVICE_DISCOVERED, "HB-00000001") + ) + + async def mock_start_listening(self): + """Mock listening function.""" + self._devices["HB-00000001"] = device + self._udp_task = asyncio.create_task(device_discovery_task(self)) + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.start_listening", + autospec=True, + side_effect=mock_start_listening, + ) as mock_function: + yield mock_function diff --git a/tests/components/weatherflow/fixtures/device.json b/tests/components/weatherflow/fixtures/device.json new file mode 100644 index 00000000000000..a9653c71cb0901 --- /dev/null +++ b/tests/components/weatherflow/fixtures/device.json @@ -0,0 +1,13 @@ +{ + "serial_number": "ST-00000001", + "type": "device_status", + "hub_sn": "HB-00000001", + "timestamp": 1510855923, + "uptime": 2189, + "voltage": 3.5, + "firmware_revision": 17, + "rssi": -17, + "hub_rssi": -87, + "sensor_status": 0, + "debug": 0 +} diff --git a/tests/components/weatherflow/test_config_flow.py b/tests/components/weatherflow/test_config_flow.py new file mode 100644 index 00000000000000..4188c73723094d --- /dev/null +++ b/tests/components/weatherflow/test_config_flow.py @@ -0,0 +1,91 @@ +"""Tests for WeatherFlow.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest +from pyweatherflowudp.errors import AddressInUseError + +from homeassistant import config_entries +from homeassistant.components.weatherflow.const import ( + DOMAIN, + ERROR_MSG_ADDRESS_IN_USE, + ERROR_MSG_CANNOT_CONNECT, + ERROR_MSG_NO_DEVICE_FOUND, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_single_instance( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_has_devices: AsyncMock, +) -> None: + """Test more than one instance.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_devices_with_mocks( + hass: HomeAssistant, + mock_start: AsyncMock, + mock_stop: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test getting user input.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {} + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + (asyncio.TimeoutError, ERROR_MSG_NO_DEVICE_FOUND), + (asyncio.exceptions.CancelledError, ERROR_MSG_CANNOT_CONNECT), + (AddressInUseError, ERROR_MSG_ADDRESS_IN_USE), + ], +) +async def test_devices_with_various_mocks_errors( + hass: HomeAssistant, + mock_start: AsyncMock, + mock_stop: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error_msg: str, +) -> None: + """Test the various on error states - then finally complete the test.""" + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.on", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == error_msg + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {} diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 4634a77a8daf6f..459deaae4c5d3e 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -3,11 +3,11 @@ from typing import Any from urllib.parse import urlparse +from aiohttp.test_utils import TestClient + from homeassistant.components.webhook import async_generate_url -from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -21,7 +21,7 @@ class WebhookResponse: async def call_webhook( - hass: HomeAssistant, webhook_id: str, data: dict[str, Any], client + hass: HomeAssistant, webhook_id: str, data: dict[str, Any], client: TestClient ) -> WebhookResponse: """Call the webhook.""" webhook_url = async_generate_url(hass, webhook_id) @@ -34,32 +34,22 @@ async def call_webhook( # Wait for remaining tasks to complete. await hass.async_block_till_done() - data: dict[str, Any] = await resp.json() + data = await resp.json() resp.close() return WebhookResponse(message=data["message"], message_code=data["code"]) -async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, enable_webhooks: bool = True +) -> None: """Fixture for setting up the component.""" config_entry.add_to_hass(hass) - await async_process_ha_core_config( - hass, - {"internal_url": "http://example.local:8123"}, - ) + if enable_webhooks: + await async_process_ha_core_config( + hass, + {"external_url": "https://example.local:8123"}, + ) await hass.config_entries.async_setup(config_entry.entry_id) - - -async def enable_webhooks(hass: HomeAssistant) -> None: - """Enable webhooks.""" - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_USE_WEBHOOK: True, - } - }, - ) diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py deleted file mode 100644 index 7680b19e28901e..00000000000000 --- a/tests/components/withings/common.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Common data for for the withings component tests.""" -from __future__ import annotations - -from dataclasses import dataclass -from http import HTTPStatus -from unittest.mock import MagicMock -from urllib.parse import urlparse - -from aiohttp.test_utils import TestClient -import arrow -from withings_api.common import ( - MeasureGetMeasResponse, - NotifyAppli, - NotifyListResponse, - SleepGetSummaryResponse, - UserGetDeviceResponse, -) - -from homeassistant import data_entry_flow -import homeassistant.components.api as api -from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN -import homeassistant.components.webhook as webhook -from homeassistant.components.withings.common import ( - ConfigEntryWithingsApi, - DataManager, - get_all_data_managers, -) -import homeassistant.components.withings.const as const -from homeassistant.components.withings.entity import WithingsEntityDescription -from homeassistant.config import async_process_ha_core_config -from homeassistant.config_entries import SOURCE_USER, ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow, entity_registry as er -from homeassistant.helpers.config_entry_oauth2_flow import AUTH_CALLBACK_PATH -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util - -from tests.common import MockConfigEntry -from tests.components.withings import WebhookResponse -from tests.test_util.aiohttp import AiohttpClientMocker - - -@dataclass -class ProfileConfig: - """Data representing a user profile.""" - - profile: str - user_id: int - api_response_user_get_device: UserGetDeviceResponse | Exception - api_response_measure_get_meas: MeasureGetMeasResponse | Exception - api_response_sleep_get_summary: SleepGetSummaryResponse | Exception - api_response_notify_list: NotifyListResponse | Exception - api_response_notify_revoke: Exception | None - - -def new_profile_config( - profile: str, - user_id: int, - api_response_user_get_device: UserGetDeviceResponse | Exception | None = None, - api_response_measure_get_meas: MeasureGetMeasResponse | Exception | None = None, - api_response_sleep_get_summary: SleepGetSummaryResponse | Exception | None = None, - api_response_notify_list: NotifyListResponse | Exception | None = None, - api_response_notify_revoke: Exception | None = None, -) -> ProfileConfig: - """Create a new profile config immutable object.""" - return ProfileConfig( - profile=profile, - user_id=user_id, - api_response_user_get_device=api_response_user_get_device - or UserGetDeviceResponse(devices=[]), - api_response_measure_get_meas=api_response_measure_get_meas - or MeasureGetMeasResponse( - measuregrps=[], - more=False, - offset=0, - timezone=dt_util.UTC, - updatetime=arrow.get(12345), - ), - api_response_sleep_get_summary=api_response_sleep_get_summary - or SleepGetSummaryResponse(more=False, offset=0, series=[]), - api_response_notify_list=api_response_notify_list - or NotifyListResponse(profiles=[]), - api_response_notify_revoke=api_response_notify_revoke, - ) - - -class ComponentFactory: - """Manages the setup and unloading of the withing component and profiles.""" - - def __init__( - self, - hass: HomeAssistant, - api_class_mock: MagicMock, - hass_client_no_auth, - aioclient_mock: AiohttpClientMocker, - ) -> None: - """Initialize the object.""" - self._hass = hass - self._api_class_mock = api_class_mock - self._hass_client = hass_client_no_auth - self._aioclient_mock = aioclient_mock - self._client_id = None - self._client_secret = None - self._profile_configs: tuple[ProfileConfig, ...] = () - - async def configure_component( - self, - client_id: str = "my_client_id", - client_secret: str = "my_client_secret", - profile_configs: tuple[ProfileConfig, ...] = (), - ) -> None: - """Configure the wihings component.""" - self._client_id = client_id - self._client_secret = client_secret - self._profile_configs = profile_configs - - hass_config = { - "homeassistant": { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", - }, - api.DOMAIN: {}, - const.DOMAIN: { - CONF_CLIENT_ID: self._client_id, - CONF_CLIENT_SECRET: self._client_secret, - const.CONF_USE_WEBHOOK: True, - }, - } - - await async_process_ha_core_config(self._hass, hass_config.get("homeassistant")) - assert await async_setup_component(self._hass, HA_DOMAIN, {}) - assert await async_setup_component(self._hass, webhook.DOMAIN, hass_config) - - assert await async_setup_component(self._hass, const.DOMAIN, hass_config) - await self._hass.async_block_till_done() - - @staticmethod - def _setup_api_method(api_method, value) -> None: - if isinstance(value, Exception): - api_method.side_effect = value - else: - api_method.return_value = value - - async def setup_profile(self, user_id: int) -> ConfigEntryWithingsApi: - """Set up a user profile through config flows.""" - profile_config = next( - iter( - [ - profile_config - for profile_config in self._profile_configs - if profile_config.user_id == user_id - ] - ) - ) - - api_mock: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi) - api_mock.config_entry = MockConfigEntry( - domain=const.DOMAIN, - data={"profile": profile_config.profile}, - ) - ComponentFactory._setup_api_method( - api_mock.user_get_device, profile_config.api_response_user_get_device - ) - ComponentFactory._setup_api_method( - api_mock.sleep_get_summary, profile_config.api_response_sleep_get_summary - ) - ComponentFactory._setup_api_method( - api_mock.measure_get_meas, profile_config.api_response_measure_get_meas - ) - ComponentFactory._setup_api_method( - api_mock.notify_list, profile_config.api_response_notify_list - ) - ComponentFactory._setup_api_method( - api_mock.notify_revoke, profile_config.api_response_notify_revoke - ) - - self._api_class_mock.reset_mocks() - self._api_class_mock.return_value = api_mock - - # Get the withings config flow. - result = await self._hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": SOURCE_USER} - ) - assert result - - state = config_entry_oauth2_flow._encode_jwt( - self._hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP - assert result["url"] == ( - "https://account.withings.com/oauth2_user/authorize2?" - f"response_type=code&client_id={self._client_id}&" - "redirect_uri=https://example.com/auth/external/callback&" - f"state={state}" - "&scope=user.info,user.metrics,user.activity,user.sleepevents" - ) - - # Simulate user being redirected from withings site. - client: TestClient = await self._hass_client() - resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - self._aioclient_mock.clear_requests() - self._aioclient_mock.post( - "https://wbsapi.withings.net/v2/oauth2", - json={ - "body": { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - "userid": profile_config.user_id, - }, - }, - ) - - # Present user with a list of profiles to choose from. - result = await self._hass.config_entries.flow.async_configure(result["flow_id"]) - assert result.get("type") == "form" - assert result.get("step_id") == "profile" - assert "profile" in result.get("data_schema").schema - - # Provide the user profile. - result = await self._hass.config_entries.flow.async_configure( - result["flow_id"], {const.PROFILE: profile_config.profile} - ) - - # Finish the config flow by calling it again. - assert result.get("type") == "create_entry" - assert result.get("result") - config_data = result.get("result").data - assert config_data.get(const.PROFILE) == profile_config.profile - assert config_data.get("auth_implementation") == const.DOMAIN - assert config_data.get("token") - - # Wait for remaining tasks to complete. - await self._hass.async_block_till_done() - - # Mock the webhook. - data_manager = get_data_manager_by_user_id(self._hass, user_id) - self._aioclient_mock.clear_requests() - self._aioclient_mock.request( - "HEAD", - data_manager.webhook_config.url, - ) - - return self._api_class_mock.return_value - - async def call_webhook(self, user_id: int, appli: NotifyAppli) -> WebhookResponse: - """Call the webhook to notify of data changes.""" - client: TestClient = await self._hass_client() - data_manager = get_data_manager_by_user_id(self._hass, user_id) - - resp = await client.post( - urlparse(data_manager.webhook_config.url).path, - data={"userid": user_id, "appli": appli.value}, - ) - - # Wait for remaining tasks to complete. - await self._hass.async_block_till_done() - - data = await resp.json() - resp.close() - - return WebhookResponse(message=data["message"], message_code=data["code"]) - - async def unload(self, profile: ProfileConfig) -> None: - """Unload the component for a specific user.""" - config_entries = get_config_entries_for_user_id(self._hass, profile.user_id) - - for config_entry in config_entries: - await config_entry.async_unload(self._hass) - - await self._hass.async_block_till_done() - - assert not get_data_manager_by_user_id(self._hass, profile.user_id) - - -def get_config_entries_for_user_id( - hass: HomeAssistant, user_id: int -) -> tuple[ConfigEntry]: - """Get a list of config entries that apply to a specific withings user.""" - return tuple( - config_entry - for config_entry in hass.config_entries.async_entries(const.DOMAIN) - if config_entry.data.get("token", {}).get("userid") == user_id - ) - - -def get_data_manager_by_user_id( - hass: HomeAssistant, user_id: int -) -> DataManager | None: - """Get a data manager by the user id.""" - return next( - iter( - [ - data_manager - for data_manager in get_all_data_managers(hass) - if data_manager.user_id == user_id - ] - ), - None, - ) - - -async def async_get_entity_id( - hass: HomeAssistant, - description: WithingsEntityDescription, - user_id: int, - platform: str, -) -> str | None: - """Get an entity id for a user's attribute.""" - entity_registry = er.async_get(hass) - unique_id = f"withings_{user_id}_{description.measurement.value}" - - return entity_registry.async_get_entity_id(platform, const.DOMAIN, unique_id) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index f1df0e3a65a336..3fc2a3c6461d5a 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -20,10 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import ComponentFactory - from tests.common import MockConfigEntry, load_json_object_fixture -from tests.test_util.aiohttp import AiohttpClientMocker CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -38,22 +35,6 @@ WEBHOOK_ID = "55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" -@pytest.fixture -def component_factory( - hass: HomeAssistant, - hass_client_no_auth, - aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, -): - """Return a factory for initializing the withings component.""" - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi" - ) as api_class_mock: - yield ComponentFactory( - hass, api_class_mock, hass_client_no_auth, aioclient_mock - ) - - @pytest.fixture(name="scopes") def mock_scopes() -> list[str]: """Fixture to set the scopes present in the OAuth token.""" @@ -78,8 +59,31 @@ def mock_expires_at() -> int: return time.time() + 3600 -@pytest.fixture(name="config_entry") -def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: +@pytest.fixture +def webhook_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Withings entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=str(USER_ID), + data={ + "auth_implementation": DOMAIN, + "token": { + "status": 0, + "userid": str(USER_ID), + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": ",".join(scopes), + }, + "profile": TITLE, + "webhook_id": WEBHOOK_ID, + }, + ) + + +@pytest.fixture +def cloudhook_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: """Create Withings entry in Home Assistant.""" return MockConfigEntry( domain=DOMAIN, @@ -97,9 +101,30 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: }, "profile": TITLE, "webhook_id": WEBHOOK_ID, + "cloudhook_url": "https://hooks.nabu.casa/ABCD", }, - options={ - "use_webhook": True, + ) + + +@pytest.fixture +def polling_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Withings entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=str(USER_ID), + data={ + "auth_implementation": DOMAIN, + "token": { + "status": 0, + "userid": str(USER_ID), + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": ",".join(scopes), + }, + "profile": TITLE, + "webhook_id": WEBHOOK_ID, }, ) @@ -123,21 +148,22 @@ def mock_withings(): ) with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", + "homeassistant.components.withings.ConfigEntryWithingsApi", return_value=mock, ): yield mock -@pytest.fixture(name="disable_webhook_delay") +@pytest.fixture(name="disable_webhook_delay", autouse=True) def disable_webhook_delay(): """Disable webhook delay.""" mock = AsyncMock() with patch( - "homeassistant.components.withings.common.SUBSCRIBE_DELAY", timedelta(seconds=0) + "homeassistant.components.withings.coordinator.SUBSCRIBE_DELAY", + timedelta(seconds=0), ), patch( - "homeassistant.components.withings.common.UNSUBSCRIBE_DELAY", + "homeassistant.components.withings.coordinator.UNSUBSCRIBE_DELAY", timedelta(seconds=0), ): yield mock diff --git a/tests/components/withings/fixtures/empty_notify_list.json b/tests/components/withings/fixtures/empty_notify_list.json new file mode 100644 index 00000000000000..c905c95e4cbcc7 --- /dev/null +++ b/tests/components/withings/fixtures/empty_notify_list.json @@ -0,0 +1,3 @@ +{ + "profiles": [] +} diff --git a/tests/components/withings/fixtures/notify_list.json b/tests/components/withings/fixtures/notify_list.json index bc696db583a950..5b368a5c9792f7 100644 --- a/tests/components/withings/fixtures/notify_list.json +++ b/tests/components/withings/fixtures/notify_list.json @@ -8,13 +8,13 @@ }, { "appli": 50, - "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "callbackurl": "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", "expires": 2147483647, "comment": null }, { "appli": 51, - "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "callbackurl": "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", "expires": 2147483647, "comment": null } diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index dca9fbc6437c2d..d258986bdafad0 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -1,12 +1,14 @@ """Tests for the Withings component.""" from unittest.mock import AsyncMock +from aiohttp.client_exceptions import ClientResponseError +import pytest from withings_api.common import NotifyAppli -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from . import call_webhook, enable_webhooks, setup_integration +from . import call_webhook, setup_integration from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry @@ -16,19 +18,17 @@ async def test_binary_sensor( hass: HomeAssistant, withings: AsyncMock, - disable_webhook_delay, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" - await enable_webhooks(hass) - await setup_integration(hass, config_entry) + await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() entity_id = "binary_sensor.henk_in_bed" - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id).state == STATE_UNKNOWN resp = await call_webhook( hass, @@ -49,3 +49,27 @@ async def test_binary_sensor( assert resp.message_code == 0 await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_polling_binary_sensor( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test binary sensor.""" + await setup_integration(hass, polling_config_entry, False) + + client = await hass_client_no_auth() + + entity_id = "binary_sensor.henk_in_bed" + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + with pytest.raises(ClientResponseError): + await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + client, + ) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index d5745ae9bedf07..36edffcc346599 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for config flow.""" from unittest.mock import AsyncMock, patch -from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN +from homeassistant.components.withings.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -83,12 +83,11 @@ async def test_config_non_unique_profile( hass_client_no_auth: ClientSessionGenerator, current_request_with_host: None, withings: AsyncMock, - config_entry: MockConfigEntry, - disable_webhook_delay, + polling_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, ) -> None: """Test setup a non-unique profile.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -136,21 +135,20 @@ async def test_config_reauth_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, withings: AsyncMock, - disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth an existing profile reauthenticates the config entry.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_REAUTH, - "entry_id": config_entry.entry_id, + "entry_id": polling_config_entry.entry_id, }, - data=config_entry.data, + data=polling_config_entry.data, ) assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" @@ -199,21 +197,20 @@ async def test_config_reauth_wrong_account( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, withings: AsyncMock, - disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth with wrong account.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_REAUTH, - "entry_id": config_entry.entry_id, + "entry_id": polling_config_entry.entry_id, }, - data=config_entry.data, + data=polling_config_entry.data, ) assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" @@ -256,31 +253,3 @@ async def test_config_reauth_wrong_account( assert result assert result["type"] == FlowResultType.ABORT assert result["reason"] == "wrong_account" - - -async def test_options_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - config_entry: MockConfigEntry, - withings: AsyncMock, - disable_webhook_delay, - current_request_with_host, -) -> None: - """Test options flow.""" - await setup_integration(hass, config_entry) - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_USE_WEBHOOK: True}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_USE_WEBHOOK: True} diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 15f0fff808d88e..a3918a6ff1974d 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -1,29 +1,45 @@ """Tests for the Withings component.""" from datetime import timedelta from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import urlparse +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol -from withings_api.common import NotifyAppli +from withings_api import NotifyListResponse +from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedException +from homeassistant import config_entries +from homeassistant.components.cloud import CloudNotAvailable from homeassistant.components.webhook import async_generate_url -from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID -from homeassistant.core import HomeAssistant +from homeassistant.components.withings import CONFIG_SCHEMA, async_setup +from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_WEBHOOK_ID, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import CoreState, HomeAssistant from homeassistant.util import dt as dt_util -from . import enable_webhooks, setup_integration -from .conftest import WEBHOOK_ID +from . import call_webhook, setup_integration +from .conftest import USER_ID, WEBHOOK_ID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_mock_cloud_connection_status, + load_json_object_fixture, +) +from tests.components.cloud import mock_cloud from tests.typing import ClientSessionGenerator def config_schema_validate(withings_config) -> dict: """Assert a schema config succeeds.""" - hass_config = {const.DOMAIN: withings_config} + hass_config = {DOMAIN: withings_config} return CONFIG_SCHEMA(hass_config) @@ -40,7 +56,7 @@ def test_config_schema_basic_config() -> None: { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: True, + CONF_USE_WEBHOOK: True, } ) @@ -72,23 +88,23 @@ def test_config_schema_use_webhook() -> None: { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: True, + CONF_USE_WEBHOOK: True, } ) - assert config[const.DOMAIN][const.CONF_USE_WEBHOOK] is True + assert config[DOMAIN][CONF_USE_WEBHOOK] is True config = config_schema_validate( { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: False, + CONF_USE_WEBHOOK: False, } ) - assert config[const.DOMAIN][const.CONF_USE_WEBHOOK] is False + assert config[DOMAIN][CONF_USE_WEBHOOK] is False config_schema_assert_fail( { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: "A", + CONF_USE_WEBHOOK: "A", } ) @@ -105,13 +121,11 @@ async def test_async_setup_no_config(hass: HomeAssistant) -> None: async def test_data_manager_webhook_subscription( hass: HomeAssistant, withings: AsyncMock, - disable_webhook_delay, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test data manager webhook subscriptions.""" - await enable_webhooks(hass) - await setup_integration(hass, config_entry) + await setup_integration(hass, webhook_config_entry) await hass_client_no_auth() await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) @@ -119,7 +133,7 @@ async def test_data_manager_webhook_subscription( assert withings.async_notify_subscribe.call_count == 4 - webhook_url = "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" + webhook_url = "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) withings.async_notify_subscribe.assert_any_call( @@ -132,6 +146,26 @@ async def test_data_manager_webhook_subscription( withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) +async def test_webhook_subscription_polling_config( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test webhook subscriptions not run when polling.""" + await setup_integration(hass, polling_config_entry) + await hass_client_no_auth() + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert withings.notify_revoke.call_count == 0 + assert withings.notify_subscribe.call_count == 0 + assert withings.notify_list.call_count == 0 + + @pytest.mark.parametrize( "method", [ @@ -142,13 +176,12 @@ async def test_data_manager_webhook_subscription( async def test_requests( hass: HomeAssistant, withings: AsyncMock, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, method: str, - disable_webhook_delay, ) -> None: """Test we handle request methods Withings sends.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) @@ -159,6 +192,86 @@ async def test_requests( assert response.status == 200 +async def test_webhooks_request_data( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test calling a webhook requests data.""" + await setup_integration(hass, webhook_config_entry) + client = await hass_client_no_auth() + + assert withings.async_measure_get_meas.call_count == 1 + + await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + client, + ) + assert withings.async_measure_get_meas.call_count == 2 + + +async def test_delayed_startup( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test delayed start up.""" + hass.state = CoreState.not_running + await setup_integration(hass, webhook_config_entry) + + withings.async_notify_subscribe.assert_not_called() + client = await hass_client_no_auth() + + assert withings.async_measure_get_meas.call_count == 1 + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + client, + ) + assert withings.async_measure_get_meas.call_count == 2 + + +@pytest.mark.parametrize( + "error", + [ + UnauthorizedException(401), + AuthFailedException(500), + ], +) +async def test_triggering_reauth( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + error: Exception, +) -> None: + """Test triggering reauth.""" + await setup_integration(hass, polling_config_entry, False) + + withings.async_measure_get_meas.side_effect = error + future = dt_util.utcnow() + timedelta(minutes=10) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH + + @pytest.mark.parametrize( ("config_entry"), [ @@ -197,9 +310,207 @@ async def test_config_flow_upgrade( assert entry.unique_id == "123" assert entry.data["token"]["userid"] == 123 assert CONF_WEBHOOK_ID in entry.data - assert entry.options == { - "use_webhook": False, - } + + +async def test_setup_with_cloudhook( + hass: HomeAssistant, cloudhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test if set up with active cloud subscription and cloud hook.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, cloudhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data["cloudhook_url"] + == "https://hooks.nabu.casa/ABCD" + ) + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_removing_entry_with_cloud_unavailable( + hass: HomeAssistant, cloudhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test handling cloud unavailable when deleting entry.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook", + side_effect=CloudNotAvailable(), + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, cloudhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_setup_with_cloud( + hass: HomeAssistant, webhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test if set up with active cloud subscription.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, webhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + assert hass.components.cloud.async_is_connected() is True + fake_create_cloudhook.assert_called_once() + + assert ( + hass.config_entries.async_entries("withings")[0].data["cloudhook_url"] + == "https://hooks.nabu.casa/ABCD" + ) + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + + for config_entry in hass.config_entries.async_entries("withings"): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_setup_without_https( + hass: HomeAssistant, + webhook_config_entry: MockConfigEntry, + withings: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if set up with cloud link and without https.""" + hass.config.components.add("cloud") + with patch( + "homeassistant.helpers.network.get_url", + return_value="http://example.nabu.casa", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ) as mock_async_generate_url: + mock_async_generate_url.return_value = "http://example.com" + await setup_integration(hass, webhook_config_entry) + + await hass.async_block_till_done() + mock_async_generate_url.assert_called_once() + + assert "https and port 443 is required to register the webhook" in caplog.text + + +async def test_cloud_disconnect( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test disconnecting from the cloud.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, webhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + assert hass.components.cloud.async_is_connected() is True + + await hass.async_block_till_done() + + withings.async_notify_list.return_value = NotifyListResponse( + **load_json_object_fixture("withings/empty_notify_list.json") + ) + + assert withings.async_notify_subscribe.call_count == 6 + + async_mock_cloud_connection_status(hass, False) + await hass.async_block_till_done() + + assert withings.async_notify_revoke.call_count == 3 + + async_mock_cloud_connection_status(hass, True) + await hass.async_block_till_done() + + assert withings.async_notify_subscribe.call_count == 12 @pytest.mark.parametrize( @@ -220,15 +531,14 @@ async def test_config_flow_upgrade( async def test_webhook_post( hass: HomeAssistant, withings: AsyncMock, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, - disable_webhook_delay, body: dict[str, Any], expected_code: int, current_request_with_host: None, ) -> None: """Test webhook callback.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index cf0069c968a6f0..fe640e315a0e13 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -1,24 +1,26 @@ """Tests for the Withings component.""" +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from withings_api.common import NotifyAppli from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.withings.const import Measurement +from homeassistant.components.withings.const import DOMAIN, Measurement from homeassistant.components.withings.entity import WithingsEntityDescription from homeassistant.components.withings.sensor import SENSORS +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry from . import call_webhook, setup_integration -from .common import async_get_entity_id from .conftest import USER_ID, WEBHOOK_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { @@ -60,6 +62,19 @@ ) +async def async_get_entity_id( + hass: HomeAssistant, + description: WithingsEntityDescription, + user_id: int, + platform: str, +) -> str | None: + """Get an entity id for a user's attribute.""" + entity_registry = er.async_get(hass) + unique_id = f"withings_{user_id}_{description.measurement.value}" + + return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + + def async_assert_state_equals( entity_id: str, state_obj: State, @@ -79,12 +94,11 @@ def async_assert_state_equals( async def test_sensor_default_enabled_entities( hass: HomeAssistant, withings: AsyncMock, - config_entry: MockConfigEntry, - disable_webhook_delay, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test entities enabled by default.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, webhook_config_entry) entity_registry: EntityRegistry = er.async_get(hass) client = await hass_client_no_auth() @@ -121,12 +135,31 @@ async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, withings: AsyncMock, - disable_webhook_delay, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, ) -> None: """Test all entities.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) for sensor in SENSORS: entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) assert hass.states.get(entity_id) == snapshot + + +async def test_update_failed( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test all entities.""" + await setup_integration(hass, polling_config_entry, False) + + withings.async_measure_get_meas.side_effect = Exception + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.henk_weight") + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/wiz/snapshots/test_diagnostics.ambr b/tests/components/wiz/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..5fe9aa883a1881 --- /dev/null +++ b/tests/components/wiz/snapshots/test_diagnostics.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'homeId': '**REDACTED**', + 'mocked': 'mocked', + 'roomId': '**REDACTED**', + }), + 'entry': dict({ + 'data': dict({ + 'host': '1.1.1.1', + }), + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/wiz/test_diagnostics.py b/tests/components/wiz/test_diagnostics.py index 3bc95cf57ff029..ef26e63069b436 100644 --- a/tests/components/wiz/test_diagnostics.py +++ b/tests/components/wiz/test_diagnostics.py @@ -1,4 +1,6 @@ """Test WiZ diagnostics.""" +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from . import async_setup_integration @@ -8,17 +10,11 @@ async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" _, entry = await async_setup_integration(hass) - diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert diag == { - "data": { - "homeId": "**REDACTED**", - "mocked": "mocked", - "roomId": "**REDACTED**", - }, - "entry": {"data": {"host": "1.1.1.1"}, "title": "Mock Title"}, - } + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index 2a1b61a0a0f446..f9e44359b0070b 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -197,3 +197,53 @@ async def init_integration( "add_holidays": ["2023-12-32"], "remove_holidays": ["2023-12-32"], } +TEST_CONFIG_INCORRECT_ADD_DATE_RANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2023-12-01", "2023-12-30,2023-12-32"], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["2023-12-25", "2023-12-30,2023-12-32"], +} +TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2023-12-01", "2023-12-29,2023-12-30,2023-12-31"], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["2023-12-25", "2023-12-29,2023-12-30,2023-12-31"], +} +TEST_CONFIG_ADD_REMOVE_DATE_RANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2022-12-01", "2022-12-05,2022-12-15"], + "remove_holidays": ["2022-12-04", "2022-12-24,2022-12-26"], +} diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index a3923bfb291720..5c387e9a179573 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -12,13 +12,18 @@ from homeassistant.util.dt import UTC from . import ( + TEST_CONFIG_ADD_REMOVE_DATE_RANGE, TEST_CONFIG_DAY_AFTER_TOMORROW, TEST_CONFIG_EXAMPLE_1, TEST_CONFIG_EXAMPLE_2, TEST_CONFIG_INCLUDE_HOLIDAY, + TEST_CONFIG_INCORRECT_ADD_DATE_RANGE, + TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN, TEST_CONFIG_INCORRECT_ADD_REMOVE, TEST_CONFIG_INCORRECT_COUNTRY, TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE, + TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN, TEST_CONFIG_NO_COUNTRY, TEST_CONFIG_NO_COUNTRY_ADD_HOLIDAY, TEST_CONFIG_NO_PROVINCE, @@ -264,3 +269,53 @@ async def test_setup_incorrect_add_remove( in caplog.text ) assert "No holiday found matching '2023-12-32'" in caplog.text + + +async def test_setup_incorrect_add_holiday_ranges( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with incorrect add/remove holiday ranges.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_ADD_DATE_RANGE) + await init_integration(hass, TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN, "2") + + hass.states.get("binary_sensor.workday_sensor") + + assert "Incorrect dates in date range: 2023-12-30,2023-12-32" in caplog.text + assert ( + "Incorrect dates in date range: 2023-12-29,2023-12-30,2023-12-31" in caplog.text + ) + + +async def test_setup_incorrect_remove_holiday_ranges( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with incorrect add/remove holiday ranges.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE) + await init_integration(hass, TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN, "2") + + hass.states.get("binary_sensor.workday_sensor") + + assert "Incorrect dates in date range: 2023-12-30,2023-12-32" in caplog.text + assert ( + "Incorrect dates in date range: 2023-12-29,2023-12-30,2023-12-31" in caplog.text + ) + + +async def test_setup_date_range( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup with date range.""" + freezer.move_to( + datetime(2022, 12, 26, 12, tzinfo=UTC) + ) # Boxing Day should be working day + await init_integration(hass, TEST_CONFIG_ADD_REMOVE_DATE_RANGE) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 78cbbf97fedf84..65e6c70fa006fb 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -528,3 +528,147 @@ async def test_options_form_abort_duplicate(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "already_configured"} + + +async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: + """Test errors in setup entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-30,2022-12-32"], + CONF_REMOVE_HOLIDAYS: [], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + assert result3["errors"] == {"add_holidays": "add_holiday_range_error"} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-12-12"], + CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-32"], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + + assert result3["errors"] == {"remove_holidays": "remove_holiday_range_error"} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-01,2022-12-10"], + CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-31"], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-12", "2022-12-01,2022-12-10"], + "remove_holidays": ["2022-12-25", "2022-12-30,2022-12-31"], + "province": None, + } + + +async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: + """Test errors in options.""" + + entry = await init_integration( + hass, + { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + }, + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-32"], + "remove_holidays": [], + "province": "BW", + }, + ) + + assert result2["errors"] == {"add_holidays": "add_holiday_range_error"} + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-31"], + "remove_holidays": ["2022-13-25,2022-12-26"], + "province": "BW", + }, + ) + + assert result2["errors"] == {"remove_holidays": "remove_holiday_range_error"} + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-31"], + "remove_holidays": ["2022-12-25,2022-12-26"], + "province": "BW", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-31"], + "remove_holidays": ["2022-12-25,2022-12-26"], + "province": "BW", + } diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py new file mode 100644 index 00000000000000..38b2142dfb7114 --- /dev/null +++ b/tests/components/workday/test_repairs.py @@ -0,0 +1,399 @@ +"""Test repairs for unifiprotect.""" +from __future__ import annotations + +from http import HTTPStatus + +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.components.workday.const import DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import async_create_issue +from homeassistant.setup import async_setup_component + +from . import ( + TEST_CONFIG_INCORRECT_COUNTRY, + TEST_CONFIG_INCORRECT_PROVINCE, + init_integration, +) + +from tests.common import ANY +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +async def test_bad_country( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad country.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"title": entry.title} + assert data["step_id"] == "country" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"country": "DE"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "HB"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert not issue + + +async def test_bad_country_none( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad country with no province.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"title": entry.title} + assert data["step_id"] == "country" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"country": "DE"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "none"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert not issue + + +async def test_bad_country_no_province( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad country.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"title": entry.title} + assert data["step_id"] == "country" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"country": "SE"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert not issue + + +async def test_bad_province( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_province"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + CONF_COUNTRY: "DE", + "title": entry.title, + } + assert data["step_id"] == "province" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "BW"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert not issue + + +async def test_bad_province_none( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province selecting none.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_province"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + CONF_COUNTRY: "DE", + "title": entry.title, + } + assert data["step_id"] == "province" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "none"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert not issue + + +async def test_other_fixable_issues( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province selecting none.""" + assert await async_setup_component(hass, "repairs", {}) + await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + + issue = { + "breaks_in_ha_version": "2022.9.0dev0", + "domain": DOMAIN, + "issue_id": "issue_1", + "is_fixable": True, + "learn_more_url": "", + "severity": "error", + "translation_key": "issue_1", + } + async_create_issue( + hass, + issue["domain"], + issue["issue_id"], + breaks_in_ha_version=issue["breaks_in_ha_version"], + is_fixable=issue["is_fixable"], + is_persistent=False, + learn_more_url=None, + severity=issue["severity"], + translation_key=issue["translation_key"], + ) + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + results = msg["result"]["issues"] + assert { + "breaks_in_ha_version": "2022.9.0dev0", + "created": ANY, + "dismissed_version": None, + "domain": "workday", + "is_fixable": True, + "issue_domain": None, + "issue_id": "issue_1", + "learn_more_url": None, + "severity": "error", + "translation_key": "issue_1", + "translation_placeholders": None, + "ignored": False, + } in results + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "issue_1"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index c326228ec8b7c7..e04ff4eda03afe 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -92,7 +92,11 @@ async def write_event(self, event): async def read_event(self): """Receive.""" await asyncio.sleep(0) # force context switch - return self.responses.pop(0) + + if self.responses: + return self.responses.pop(0) + + return None async def __aenter__(self): """Enter.""" diff --git a/tests/components/wyoming/snapshots/test_wake_word.ambr b/tests/components/wyoming/snapshots/test_wake_word.ambr index 041112cb6ff82e..41518634a51efd 100644 --- a/tests/components/wyoming/snapshots/test_wake_word.ambr +++ b/tests/components/wyoming/snapshots/test_wake_word.ambr @@ -8,6 +8,6 @@ ), ]), 'timestamp': 0, - 'ww_id': 'Test Model', + 'wake_word_id': 'Test Model', }) # --- diff --git a/tests/components/wyoming/test_wake_word.py b/tests/components/wyoming/test_wake_word.py index cd156c660a88aa..b3c09d4e816202 100644 --- a/tests/components/wyoming/test_wake_word.py +++ b/tests/components/wyoming/test_wake_word.py @@ -25,7 +25,7 @@ async def test_support(hass: HomeAssistant, init_wyoming_wake_word) -> None: assert entity is not None assert entity.supported_wake_words == [ - wake_word.WakeWord(ww_id="Test Model", name="Test Model") + wake_word.WakeWord(id="Test Model", name="Test Model") ] @@ -54,7 +54,7 @@ async def audio_stream(): "homeassistant.components.wyoming.wake_word.AsyncTcpClient", MockAsyncTcpClient(client_events), ): - result = await entity.async_process_audio_stream(audio_stream()) + result = await entity.async_process_audio_stream(audio_stream(), None) assert result is not None assert result == snapshot @@ -78,7 +78,7 @@ async def audio_stream(): "homeassistant.components.wyoming.wake_word.AsyncTcpClient", MockAsyncTcpClient([None]), ): - result = await entity.async_process_audio_stream(audio_stream()) + result = await entity.async_process_audio_stream(audio_stream(), None) assert result is None @@ -103,6 +103,57 @@ async def audio_stream(): "homeassistant.components.wyoming.wake_word.AsyncTcpClient", mock_client, ), patch.object(mock_client, "read_event", side_effect=OSError("Boom!")): - result = await entity.async_process_audio_stream(audio_stream()) + result = await entity.async_process_audio_stream(audio_stream(), None) + + assert result is None + + +async def test_detect_message_with_wake_word( + hass: HomeAssistant, init_wyoming_wake_word +) -> None: + """Test that specifying a wake word id produces a Detect message with that id.""" + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + async def audio_stream(): + yield b"chunk1", 1000 + + mock_client = MockAsyncTcpClient( + [Detection(name="my-wake-word", timestamp=1000).event()] + ) + + with patch( + "homeassistant.components.wyoming.wake_word.AsyncTcpClient", + mock_client, + ): + result = await entity.async_process_audio_stream(audio_stream(), "my-wake-word") + + assert isinstance(result, wake_word.DetectionResult) + assert result.wake_word_id == "my-wake-word" + + +async def test_detect_message_with_wrong_wake_word( + hass: HomeAssistant, init_wyoming_wake_word +) -> None: + """Test that specifying a wake word id filters invalid detections.""" + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + async def audio_stream(): + yield b"chunk1", 1000 + + mock_client = MockAsyncTcpClient( + [Detection(name="not-my-wake-word", timestamp=1000).event()], + ) + + with patch( + "homeassistant.components.wyoming.wake_word.AsyncTcpClient", + mock_client, + ): + result = await entity.async_process_audio_stream(audio_stream(), "my-wake-word") assert result is None diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index b0e15a013189f0..d914c88c0c2903 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +from freezegun import freeze_time import pytest import voluptuous as vol import zigpy.backups @@ -227,6 +228,7 @@ async def test_device_cluster_commands(zha_client) -> None: assert command[TYPE] is not None +@freeze_time("2023-09-23 20:16:00+00:00") async def test_list_devices(zha_client) -> None: """Test getting ZHA devices.""" await zha_client.send_json({ID: 5, TYPE: "zha/devices"}) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index e950ff0402c866..bbc836488c2286 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -483,6 +483,12 @@ def fibaro_fgr222_shutter_state_fixture(): return json.loads(load_fixture("zwave_js/cover_fibaro_fgr222_state.json")) +@pytest.fixture(name="fibaro_fgr223_shutter_state", scope="session") +def fibaro_fgr223_shutter_state_fixture(): + """Load the Fibaro FGR223 node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_fibaro_fgr223_state.json")) + + @pytest.fixture(name="merten_507801_state", scope="session") def merten_507801_state_fixture(): """Load the Merten 507801 Shutter node state fixture data.""" @@ -1054,6 +1060,14 @@ def fibaro_fgr222_shutter_cover_fixture(client, fibaro_fgr222_shutter_state): return node +@pytest.fixture(name="fibaro_fgr223_shutter") +def fibaro_fgr223_shutter_cover_fixture(client, fibaro_fgr223_shutter_state): + """Mock a Fibaro FGR223 Shutter node.""" + node = Node(client, copy.deepcopy(fibaro_fgr223_shutter_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="merten_507801") def merten_507801_cover_fixture(client, merten_507801_state): """Mock a Merten 507801 Shutter node.""" diff --git a/tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json b/tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json new file mode 100644 index 00000000000000..b0f4992e319766 --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json @@ -0,0 +1,2325 @@ +{ + "nodeId": 10, + "index": 0, + "installerIcon": 6400, + "userIcon": 6400, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 271, + "productId": 4096, + "productType": 771, + "firmwareVersion": "5.1", + "zwavePlusVersion": 1, + "name": "fgr 223 test cover", + "location": "test location", + "deviceConfig": { + "filename": "/data/db/devices/0x010f/fgr223.json", + "isEmbedded": true, + "manufacturer": "Fibargroup", + "manufacturerId": 271, + "label": "FGR223", + "description": "Roller Shutter 3", + "devices": [ + { + "productType": 771, + "productId": 4096 + }, + { + "productType": 771, + "productId": 12288 + }, + { + "productType": 771, + "productId": 16384 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + }, + "proprietary": { + "fibaroCCs": [38] + } + }, + "label": "FGR223", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 2, + "aggregatedEndpointCount": 0, + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 10, + "index": 0, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 86, + "name": "CRC-16 Encapsulation", + "version": 1, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": true + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": true + } + ] + }, + { + "nodeId": 10, + "index": 1, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 10, + "index": 2, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 108, + "commandClassName": "Supervision", + "property": "ccSupported", + "propertyKey": 91, + "propertyName": "ccSupported", + "propertyKeyName": "91", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Switch type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Switch type", + "default": 2, + "min": 0, + "max": 2, + "states": { + "0": "Momentary switches", + "1": "Toggle switches", + "2": "Single momentary switch (S1)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Switch type" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Inputs orientation", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Inputs orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "default", + "1": "reversed" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Inputs orientation" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Outputs orientation", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Outputs orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "default", + "1": "reversed" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Outputs orientation" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 1, + "propertyName": "S1 scenes: Pressed 1 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 1 time", + "label": "S1 scenes: Pressed 1 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 1 time", + "info": "Send a Central Scene notification when S1 is pressed 1 time" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 2, + "propertyName": "S1 scenes: Pressed 2 times", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 2 times", + "label": "S1 scenes: Pressed 2 times", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 2 times", + "info": "Send a Central Scene notification when S1 is pressed 2 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 4, + "propertyName": "S1 scenes: Pressed 3 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 3 times", + "label": "S1 scenes: Pressed 3 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 3 time", + "info": "Send a Central Scene notification when S1 is pressed 3 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 8, + "propertyName": "S1 scenes: Hold down / Release", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is held down or released", + "label": "S1 scenes: Hold down / Release", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Hold down / Release", + "info": "Send a Central Scene notification when S1 is held down or released" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 1, + "propertyName": "S2 scenes: Pressed 1 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 1 time", + "label": "S2 scenes: Pressed 1 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 1 time", + "info": "Send a Central Scene notification when S2 is pressed 1 time" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 2, + "propertyName": "S2 scenes: Pressed 2 times", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 2 times", + "label": "S2 scenes: Pressed 2 times", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 2 times", + "info": "Send a Central Scene notification when S2 is pressed 2 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 4, + "propertyName": "S2 scenes: Pressed 3 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 3 times", + "label": "S2 scenes: Pressed 3 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 3 time", + "info": "Send a Central Scene notification when S2 is pressed 3 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 8, + "propertyName": "S2 scenes: Hold down / Release", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is held down or released", + "label": "S2 scenes: Hold down / Release", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Hold down / Release", + "info": "Send a Central Scene notification when S2 is held down or released" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 60, + "propertyName": "Measuring power consumed by the device itself", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Measuring power consumed by the device itself", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Function inactive", + "1": "Function active" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Measuring power consumed by the device itself" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 61, + "propertyName": "Power reports - on change", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Power reports - on change", + "default": 15, + "min": 0, + "max": 500, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Power reports - on change" + }, + "value": 15 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 62, + "propertyName": "Power reports - periodic", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Power reports - periodic", + "default": 3600, + "min": 0, + "max": 32400, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Power reports - periodic" + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 65, + "propertyName": "Energy reports - on change", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Energy reports - on change", + "default": 10, + "min": 0, + "max": 500, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Energy reports - on change" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 66, + "propertyName": "Energy reports - periodic", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Energy reports - periodic", + "default": 3600, + "min": 0, + "max": 32400, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Energy reports - periodic" + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 150, + "propertyName": "Force calibration", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Force calibration", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "device is not calibrated", + "1": "device is calibrated", + "2": "force device calibration" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Force calibration" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 151, + "propertyName": "Operating mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Operating mode", + "default": 1, + "min": 1, + "max": 6, + "states": { + "1": "roller blind", + "2": "Venetian blind", + "3": "gate w/o positioning", + "4": "gate with positioning", + "5": "roller blind with built-in driver", + "6": "roller blind with built-in driver (impulse)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Operating mode" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 152, + "propertyName": "Venetian blind - time of full turn of the slats", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Venetian blind - time of full turn of the slats", + "default": 150, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Venetian blind - time of full turn of the slats" + }, + "value": 150 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 153, + "propertyName": "Set slats back to previous position", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Set slats back to previous position", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "Main controller operation", + "1": "Controller, Momentary Switch, Limit Switch", + "2": "Controller, both Switches, Multilevel Stop" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Set slats back to previous position" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 154, + "propertyName": "Delay motor stop", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Delay motor stop after reaching end switch", + "label": "Delay motor stop", + "default": 10, + "min": 0, + "max": 255, + "unit": "1/10 sec", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Delay motor stop", + "info": "Delay motor stop after reaching end switch" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 155, + "propertyName": "Motor operation detection", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Power threshold to be interpreted as reaching a limit switch", + "label": "Motor operation detection", + "default": 10, + "min": 0, + "max": 255, + "unit": "W", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Motor operation detection", + "info": "Power threshold to be interpreted as reaching a limit switch" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 156, + "propertyName": "Time of up movement", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Time of up movement", + "default": 6000, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Time of up movement" + }, + "value": 5000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 157, + "propertyName": "Time of down movement", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Time of down movement", + "default": 6000, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Time of down movement" + }, + "value": 5000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 255, + "propertyName": "Alarm #1: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #1 is triggered", + "label": "Alarm #1: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #1: Action", + "info": "Which action to perform when Alarm #1 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 65280, + "propertyName": "Alarm #1: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #1 should be limited to", + "label": "Alarm #1: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Event/State Parameters", + "info": "Which event parameters Alarm #1 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 16711680, + "propertyName": "Alarm #1: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #1 should be limited to", + "label": "Alarm #1: Notification Status", + "default": 0, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Notification Status", + "info": "Which notification status Alarm #1 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 4278190080, + "propertyName": "Alarm #1: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #1", + "label": "Alarm #1: Notification Type", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Notification Type", + "info": "Which notification type should raise Alarm #1" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 255, + "propertyName": "Alarm #2: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #2 is triggered", + "label": "Alarm #2: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #2: Action", + "info": "Which action to perform when Alarm #2 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 65280, + "propertyName": "Alarm #2: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #2 should be limited to", + "label": "Alarm #2: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Event/State Parameters", + "info": "Which event parameters Alarm #2 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 16711680, + "propertyName": "Alarm #2: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #2 should be limited to", + "label": "Alarm #2: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Notification Status", + "info": "Which notification status Alarm #2 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 4278190080, + "propertyName": "Alarm #2: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #2", + "label": "Alarm #2: Notification Type", + "default": 5, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Notification Type", + "info": "Which notification type should raise Alarm #2" + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 255, + "propertyName": "Alarm #3: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #3 is triggered", + "label": "Alarm #3: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #3: Action", + "info": "Which action to perform when Alarm #3 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 65280, + "propertyName": "Alarm #3: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #3 should be limited to", + "label": "Alarm #3: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Event/State Parameters", + "info": "Which event parameters Alarm #3 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 16711680, + "propertyName": "Alarm #3: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #3 should be limited to", + "label": "Alarm #3: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Notification Status", + "info": "Which notification status Alarm #3 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 4278190080, + "propertyName": "Alarm #3: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #3", + "label": "Alarm #3: Notification Type", + "default": 1, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Notification Type", + "info": "Which notification type should raise Alarm #3" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 255, + "propertyName": "Alarm #4: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #4 is triggered", + "label": "Alarm #4: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #4: Action", + "info": "Which action to perform when Alarm #4 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 65280, + "propertyName": "Alarm #4: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #4 should be limited to", + "label": "Alarm #4: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Event/State Parameters", + "info": "Which event parameters Alarm #4 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 16711680, + "propertyName": "Alarm #4: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #4 should be limited to", + "label": "Alarm #4: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Notification Status", + "info": "Which notification status Alarm #4 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 4278190080, + "propertyName": "Alarm #4: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #4", + "label": "Alarm #4: Notification Type", + "default": 2, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Notification Type", + "info": "Which notification type should raise Alarm #4" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 255, + "propertyName": "Alarm #5: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #5 is triggered", + "label": "Alarm #5: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #5: Action", + "info": "Which action to perform when Alarm #5 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 65280, + "propertyName": "Alarm #5: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #5 should be limited to", + "label": "Alarm #5: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Event/State Parameters", + "info": "Which event parameters Alarm #5 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 16711680, + "propertyName": "Alarm #5: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #5 should be limited to", + "label": "Alarm #5: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Notification Status", + "info": "Which notification status Alarm #5 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 4278190080, + "propertyName": "Alarm #5: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #5", + "label": "Alarm #5: Notification Type", + "default": 4, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Notification Type", + "info": "Which notification type should raise Alarm #5" + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 271 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 771 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 4096 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "2": "NoOperationPossible" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "rf", + "propertyName": "rf", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection state", + "states": { + "0": "Unprotected", + "1": "NoControl" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Node ID with exclusive control", + "min": 1, + "max": 232, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection timeout", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.2" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["5.1", "5.1"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": "unknown" + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 99 + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 99 + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0.0 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "System hardware failure (with failure code)" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": "unknown" + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x010f:0x0303:0x1000:5.1", + "statistics": { + "commandsTX": 8, + "commandsRX": 13, + "commandsDroppedRX": 12, + "commandsDroppedTX": 0, + "timeoutResponse": 1, + "rtt": 155.4, + "rssi": -66, + "lwr": { + "protocolDataRate": 2, + "repeaters": [11], + "rssi": -56, + "repeaterRSSI": [-55] + }, + "nlwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -89, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 02ed507cabea8d..965b1ea4f1b819 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -126,12 +126,14 @@ async def test_network_status( hass: HomeAssistant, multisensor_6, controller_state, + client, integration, hass_ws_client: WebSocketGenerator, ) -> None: """Test the network status websocket command.""" entry = integration ws_client = await hass_ws_client(hass) + client.server_logging_enabled = False # Try API call with entry ID with patch( @@ -150,6 +152,7 @@ async def test_network_status( assert result["client"]["ws_server_url"] == "ws://test:3000/zjs" assert result["client"]["server_version"] == "1.0.0" + assert not result["client"]["server_logging_enabled"] assert result["controller"]["inclusion_state"] == InclusionState.IDLE # Try API call with device ID @@ -906,7 +909,7 @@ async def test_add_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1179,7 +1182,7 @@ async def test_provision_smart_start_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1283,7 +1286,7 @@ async def test_unprovision_smart_start_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1355,7 +1358,7 @@ async def test_get_provisioning_entries( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1450,7 +1453,7 @@ async def test_parse_qr_code_string( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1517,7 +1520,7 @@ async def test_try_parse_dsk_from_qr_code_string( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1599,7 +1602,7 @@ async def test_cancel_inclusion_exclusion( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test FailedZWaveCommand is caught with patch( @@ -1617,7 +1620,7 @@ async def test_cancel_inclusion_exclusion( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1736,7 +1739,7 @@ async def test_remove_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2081,7 +2084,7 @@ async def test_replace_failed_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2132,7 +2135,7 @@ async def test_remove_failed_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" await ws_client.send_json( { @@ -2187,13 +2190,13 @@ async def test_remove_failed_node( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_begin_healing_network( +async def test_begin_rebuilding_routes( hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator, ) -> None: - """Test the begin_healing_network websocket command.""" + """Test the begin_rebuilding_routes websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2202,7 +2205,7 @@ async def test_begin_healing_network( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/begin_healing_network", + TYPE: "zwave_js/begin_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2213,13 +2216,13 @@ async def test_begin_healing_network( # Test FailedZWaveCommand is caught with patch( - f"{CONTROLLER_PATCH_PREFIX}.async_begin_healing_network", + f"{CONTROLLER_PATCH_PREFIX}.async_begin_rebuilding_routes", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/begin_healing_network", + TYPE: "zwave_js/begin_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2227,7 +2230,7 @@ async def test_begin_healing_network( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2236,7 +2239,7 @@ async def test_begin_healing_network( await ws_client.send_json( { ID: 5, - TYPE: "zwave_js/begin_healing_network", + TYPE: "zwave_js/begin_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2246,17 +2249,21 @@ async def test_begin_healing_network( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_subscribe_heal_network_progress( - hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +async def test_subscribe_rebuild_routes_progress( + hass: HomeAssistant, + integration, + client, + nortek_thermostat, + hass_ws_client: WebSocketGenerator, ) -> None: - """Test the subscribe_heal_network_progress command.""" + """Test the subscribe_rebuild_routes_progress command.""" entry = integration ws_client = await hass_ws_client(hass) await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/subscribe_heal_network_progress", + TYPE: "zwave_js/subscribe_rebuild_routes_progress", ENTRY_ID: entry.entry_id, } ) @@ -2265,19 +2272,19 @@ async def test_subscribe_heal_network_progress( assert msg["success"] assert msg["result"] is None - # Fire heal network progress + # Fire rebuild routes progress event = Event( - "heal network progress", + "rebuild routes progress", { "source": "controller", - "event": "heal network progress", + "event": "rebuild routes progress", "progress": {67: "pending"}, }, ) client.driver.controller.receive_event(event) msg = await ws_client.receive_json() - assert msg["event"]["event"] == "heal network progress" - assert msg["event"]["heal_node_status"] == {"67": "pending"} + assert msg["event"]["event"] == "rebuild routes progress" + assert msg["event"]["rebuild_routes_status"] == {"67": "pending"} # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2286,7 +2293,7 @@ async def test_subscribe_heal_network_progress( await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/subscribe_heal_network_progress", + TYPE: "zwave_js/subscribe_rebuild_routes_progress", ENTRY_ID: entry.entry_id, } ) @@ -2296,21 +2303,25 @@ async def test_subscribe_heal_network_progress( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_subscribe_heal_network_progress_initial_value( - hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +async def test_subscribe_rebuild_routes_progress_initial_value( + hass: HomeAssistant, + integration, + client, + nortek_thermostat, + hass_ws_client: WebSocketGenerator, ) -> None: - """Test subscribe_heal_network_progress command when heal network in progress.""" + """Test subscribe_rebuild_routes_progress command when rebuild routes in progress.""" entry = integration ws_client = await hass_ws_client(hass) - assert not client.driver.controller.heal_network_progress + assert not client.driver.controller.rebuild_routes_progress - # Fire heal network progress before sending heal network progress command + # Fire rebuild routes progress before sending rebuild routes progress command event = Event( - "heal network progress", + "rebuild routes progress", { "source": "controller", - "event": "heal network progress", + "event": "rebuild routes progress", "progress": {67: "pending"}, }, ) @@ -2319,7 +2330,7 @@ async def test_subscribe_heal_network_progress_initial_value( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/subscribe_heal_network_progress", + TYPE: "zwave_js/subscribe_rebuild_routes_progress", ENTRY_ID: entry.entry_id, } ) @@ -2329,13 +2340,13 @@ async def test_subscribe_heal_network_progress_initial_value( assert msg["result"] == {"67": "pending"} -async def test_stop_healing_network( +async def test_stop_rebuilding_routes( hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator, ) -> None: - """Test the stop_healing_network websocket command.""" + """Test the stop_rebuilding_routes websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2344,7 +2355,7 @@ async def test_stop_healing_network( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/stop_healing_network", + TYPE: "zwave_js/stop_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2355,13 +2366,13 @@ async def test_stop_healing_network( # Test FailedZWaveCommand is caught with patch( - f"{CONTROLLER_PATCH_PREFIX}.async_stop_healing_network", + f"{CONTROLLER_PATCH_PREFIX}.async_stop_rebuilding_routes", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/stop_healing_network", + TYPE: "zwave_js/stop_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2369,7 +2380,7 @@ async def test_stop_healing_network( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2378,7 +2389,7 @@ async def test_stop_healing_network( await ws_client.send_json( { ID: 5, - TYPE: "zwave_js/stop_healing_network", + TYPE: "zwave_js/stop_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2388,14 +2399,14 @@ async def test_stop_healing_network( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_heal_node( +async def test_rebuild_node_routes( hass: HomeAssistant, multisensor_6, integration, client, hass_ws_client: WebSocketGenerator, ) -> None: - """Test the heal_node websocket command.""" + """Test the rebuild_node_routes websocket command.""" entry = integration ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) @@ -2405,7 +2416,7 @@ async def test_heal_node( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/heal_node", + TYPE: "zwave_js/rebuild_node_routes", DEVICE_ID: device.id, } ) @@ -2416,13 +2427,13 @@ async def test_heal_node( # Test FailedZWaveCommand is caught with patch( - f"{CONTROLLER_PATCH_PREFIX}.async_heal_node", + f"{CONTROLLER_PATCH_PREFIX}.async_rebuild_node_routes", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/heal_node", + TYPE: "zwave_js/rebuild_node_routes", DEVICE_ID: device.id, } ) @@ -2430,7 +2441,7 @@ async def test_heal_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2439,7 +2450,7 @@ async def test_heal_node( await ws_client.send_json( { ID: 5, - TYPE: "zwave_js/heal_node", + TYPE: "zwave_js/rebuild_node_routes", DEVICE_ID: device.id, } ) @@ -2558,7 +2569,7 @@ async def test_refresh_node_info( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2635,7 +2646,7 @@ async def test_refresh_node_values( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2729,7 +2740,7 @@ async def test_refresh_node_cc_values( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2954,7 +2965,7 @@ async def test_set_config_parameter( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3312,7 +3323,7 @@ async def test_subscribe_log_updates( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3465,7 +3476,7 @@ async def test_update_log_config( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3569,13 +3580,10 @@ async def test_data_collection( result = msg["result"] assert result is None - assert len(client.async_send_command.call_args_list) == 2 + assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args_list[0][0][0] assert args["command"] == "driver.enable_statistics" assert args["applicationName"] == "Home Assistant" - args = client.async_send_command.call_args_list[1][0][0] - assert args["command"] == "driver.enable_error_reporting" - assert entry.data[CONF_DATA_COLLECTION_OPTED_IN] client.async_send_command.reset_mock() @@ -3616,7 +3624,7 @@ async def test_data_collection( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test FailedZWaveCommand is caught with patch( @@ -3635,7 +3643,7 @@ async def test_data_collection( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3710,7 +3718,7 @@ async def test_abort_firmware_update( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3787,7 +3795,7 @@ async def test_is_node_firmware_update_in_progress( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4153,7 +4161,7 @@ async def test_get_node_firmware_update_capabilities( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4224,7 +4232,7 @@ async def test_is_any_ota_firmware_update_in_progress( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4300,7 +4308,7 @@ async def test_check_for_config_updates( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4367,7 +4375,7 @@ async def test_install_config_update( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index e51b3751ac8e85..fc593de883bb65 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -47,7 +47,8 @@ BLIND_COVER_ENTITY = "cover.window_blind_controller" SHUTTER_COVER_ENTITY = "cover.flush_shutter" AEOTEC_SHUTTER_COVER_ENTITY = "cover.nano_shutter_v_3" -FIBARO_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" +FIBARO_FGR_222_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" +FIBARO_FGR_223_SHUTTER_COVER_ENTITY = "cover.fgr_223_test_cover" LOGGER.setLevel(logging.DEBUG) @@ -238,7 +239,7 @@ async def test_fibaro_fgr222_shutter_cover( hass: HomeAssistant, client, fibaro_fgr222_shutter, integration ) -> None: """Test tilt function of the Fibaro Shutter devices.""" - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) assert state assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER @@ -249,7 +250,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -271,7 +272,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -293,7 +294,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, blocking=True, ) @@ -330,7 +331,101 @@ async def test_fibaro_fgr222_shutter_cover( }, ) fibaro_fgr222_shutter.receive_event(event) - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) + assert state + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + +async def test_fibaro_fgr223_shutter_cover( + hass: HomeAssistant, client, fibaro_fgr223_shutter, integration +) -> None: + """Test tilt function of the Fibaro Shutter devices.""" + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER + + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + # Test opening tilts + await hass.services.async_call( + DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 99 + + client.async_send_command.reset_mock() + # Test closing tilts + await hass.services.async_call( + DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 0 + + client.async_send_command.reset_mock() + # Test setting tilt position + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 12 + + # Test some tilt + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 10, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 2, + "property": "currentValue", + "newValue": 99, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + fibaro_fgr223_shutter.receive_event(event) + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) assert state assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 @@ -694,13 +789,42 @@ async def test_fibaro_fgr222_shutter_cover_no_tilt( client.driver.controller.emit("node added", {"node": node}) await hass.async_block_till_done() - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) assert state assert state.state == STATE_UNKNOWN assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes +async def test_fibaro_fgr223_shutter_cover_no_tilt( + hass: HomeAssistant, client, fibaro_fgr223_shutter_state, integration +) -> None: + """Test absence of tilt function for Fibaro Shutter roller blind. + + Fibaro Shutter devices can have operating mode set to roller blind (1). + """ + node_state = replace_value_of_zwave_value( + fibaro_fgr223_shutter_state, + [ + ZwaveValueMatcher( + property_=151, + command_class=CommandClass.CONFIGURATION, + endpoint=0, + ), + ], + 1, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) + assert state + assert state.state == STATE_OPEN + assert ATTR_CURRENT_POSITION in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes + + async def test_iblinds_v3_cover( hass: HomeAssistant, client, iblinds_v3, integration ) -> None: diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index fec9ec4cbbb4cd..ba0bbbe087d2ce 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -144,6 +144,7 @@ async def test_if_notification_notification_fires( "source": "node", "event": "notification", "nodeId": node.node_id, + "endpointIndex": 0, "ccId": 113, "args": { "type": 6, @@ -273,6 +274,7 @@ async def test_if_entry_control_notification_fires( "source": "node", "event": "notification", "nodeId": node.node_id, + "endpointIndex": 0, "ccId": 111, "args": { "eventType": 5, diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index e831e1dc7e8e63..80b179248d8aa0 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -156,6 +156,7 @@ async def test_notifications( "source": "node", "event": "notification", "nodeId": 32, + "endpointIndex": 0, "ccId": 113, "args": { "type": 6, @@ -172,6 +173,7 @@ async def test_notifications( assert len(events) == 1 assert events[0].data["home_id"] == client.driver.controller.home_id assert events[0].data["node_id"] == 32 + assert events[0].data["endpoint"] == 0 assert events[0].data["type"] == 6 assert events[0].data["event"] == 5 assert events[0].data["label"] == "Access Control" @@ -187,6 +189,7 @@ async def test_notifications( "source": "node", "event": "notification", "nodeId": 32, + "endpointIndex": 0, "ccId": 111, "args": { "eventType": 5, @@ -204,6 +207,7 @@ async def test_notifications( assert len(events) == 2 assert events[1].data["home_id"] == client.driver.controller.home_id assert events[1].data["node_id"] == 32 + assert events[0].data["endpoint"] == 0 assert events[1].data["event_type"] == 5 assert events[1].data["event_type_label"] == "test1" assert events[1].data["data_type"] == 2 @@ -219,6 +223,7 @@ async def test_notifications( "source": "node", "event": "notification", "nodeId": 32, + "endpointIndex": 0, "ccId": 38, "args": {"eventType": 4, "eventTypeLabel": "test1", "direction": "up"}, }, @@ -230,6 +235,7 @@ async def test_notifications( assert len(events) == 3 assert events[2].data["home_id"] == client.driver.controller.home_id assert events[2].data["node_id"] == 32 + assert events[0].data["endpoint"] == 0 assert events[2].data["event_type"] == 4 assert events[2].data["event_type_label"] == "test1" assert events[2].data["direction"] == "up" @@ -320,6 +326,7 @@ async def test_power_level_notification( "source": "node", "event": "notification", "nodeId": 7, + "endpointIndex": 0, "ccId": 115, "args": { "commandClassName": "Powerlevel", @@ -363,6 +370,7 @@ async def test_unknown_notification( "source": "node", "event": "notification", "nodeId": node.node_id, + "endpointIndex": 0, "ccId": 0, "args": { "commandClassName": "No Operation", diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 6985a7bf252128..1203997839e95b 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1,6 +1,7 @@ """Test the Z-Wave JS init module.""" import asyncio from copy import deepcopy +import logging from unittest.mock import AsyncMock, call, patch import pytest @@ -11,6 +12,7 @@ from zwave_js_server.model.version import VersionInfo from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id @@ -23,6 +25,7 @@ entity_registry as er, issue_registry as ir, ) +from homeassistant.setup import async_setup_component from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY @@ -1550,3 +1553,94 @@ async def test_identify_event( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] + + +async def test_server_logging(hass: HomeAssistant, client) -> None: + """Test automatic server logging functionality.""" + + def _reset_mocks(): + client.async_send_command.reset_mock() + client.enable_server_logging.reset_mock() + client.disable_server_logging.reset_mock() + + # Set server logging to disabled + client.server_logging_enabled = False + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Setup logger and set log level to debug to trigger event listener + assert await async_setup_component(hass, "logger", {"logger": {}}) + assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.INFO + client.async_send_command.reset_mock() + await hass.services.async_call( + LOGGER_DOMAIN, SERVICE_SET_LEVEL, {"zwave_js_server": "debug"}, blocking=True + ) + await hass.async_block_till_done() + assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG + + # Validate that the server logging was enabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "debug"}, + } + assert client.enable_server_logging.called + assert not client.disable_server_logging.called + + _reset_mocks() + + # Emulate server by setting log level to debug + event = Event( + type="log config updated", + data={ + "source": "driver", + "event": "log config updated", + "config": { + "enabled": False, + "level": "debug", + "logToFile": True, + "filename": "test", + "forceConsole": True, + }, + }, + ) + client.driver.receive_event(event) + + # "Enable" server logging and unload the entry + client.server_logging_enabled = True + await hass.config_entries.async_unload(entry.entry_id) + + # Validate that the server logging was disabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "info"}, + } + assert not client.enable_server_logging.called + assert client.disable_server_logging.called + + _reset_mocks() + + # Validate that the server logging doesn't get enabled because HA thinks it already + # is enabled + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(client.async_send_command.call_args_list) == 0 + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called + + _reset_mocks() + + # "Disable" server logging and unload the entry + client.server_logging_enabled = False + await hass.config_entries.async_unload(entry.entry_id) + + # Validate that the server logging was not disabled because HA thinks it is already + # is disabled + assert len(client.async_send_command.call_args_list) == 0 + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 9314b9155f5e97..4c3aa9f54996b9 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -38,24 +38,54 @@ LATEST_VERSION_FIRMWARE = { "version": "11.2.4", "changelog": "blah 2", + "channel": "stable", "files": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], + "downgrade": True, + "normalizedVersion": "11.2.4", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, } FIRMWARE_UPDATES = { "updates": [ { "version": "10.11.1", "changelog": "blah 1", + "channel": "stable", "files": [ {"target": 0, "url": "https://example1.com", "integrity": "sha1"} ], + "downgrade": True, + "normalizedVersion": "10.11.1", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, }, LATEST_VERSION_FIRMWARE, { "version": "11.1.5", "changelog": "blah 3", + "channel": "stable", "files": [ {"target": 0, "url": "https://example3.com", "integrity": "sha3"} ], + "downgrade": True, + "normalizedVersion": "11.1.5", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, }, ] } @@ -745,7 +775,23 @@ async def test_update_entity_full_restore_data_update_available( assert client.async_send_command.call_args_list[1][0][0] == { "command": "controller.firmware_update_ota", "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, - "updates": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], + "updateInfo": { + "version": "11.2.4", + "changelog": "blah 2", + "channel": "stable", + "files": [ + {"target": 0, "url": "https://example2.com", "integrity": "sha2"} + ], + "downgrade": True, + "normalizedVersion": "11.2.4", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, + }, } install_task.cancel() diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 5163dd0ca6d5a1..8e4409daa547de 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1053,6 +1053,7 @@ async def test_multiple_runs_wait(hass: HomeAssistant, action_type) -> None: hass.states.async_set("switch.test", "on") hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) assert script_obj.is_running assert len(events) == 1 @@ -1062,6 +1063,7 @@ async def test_multiple_runs_wait(hass: HomeAssistant, action_type) -> None: wait_started_flag.clear() hass.async_create_task(script_obj.async_run()) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) except (AssertionError, asyncio.TimeoutError): await script_obj.async_stop() raise @@ -4079,6 +4081,7 @@ async def test_script_mode_2( hass.states.async_set("switch.test", "on") hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) assert script_obj.is_running assert len(events) == 1 @@ -4089,6 +4092,7 @@ async def test_script_mode_2( wait_started_flag.clear() hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) assert script_obj.is_running assert len(events) == 2 diff --git a/tests/test_loader.py b/tests/test_loader.py index b62e25b79e3a2c..4a03a7379b0fe2 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -11,35 +11,46 @@ from .common import MockModule, async_get_persistent_notifications, mock_integration -async def test_component_dependencies(hass: HomeAssistant) -> None: - """Test if we can get the proper load order of components.""" +async def test_circular_component_dependencies(hass: HomeAssistant) -> None: + """Test if we can detect circular dependencies of components.""" mock_integration(hass, MockModule("mod1")) mock_integration(hass, MockModule("mod2", ["mod1"])) - mod_3 = mock_integration(hass, MockModule("mod3", ["mod2"])) + mock_integration(hass, MockModule("mod3", ["mod1"])) + mod_4 = mock_integration(hass, MockModule("mod4", ["mod2", "mod3"])) - assert {"mod1", "mod2", "mod3"} == await loader._async_component_dependencies( - hass, "mod_3", mod_3, set(), set() - ) - - # Create circular dependency - mock_integration(hass, MockModule("mod1", ["mod3"])) + deps = await loader._async_component_dependencies(hass, mod_4) + assert deps == {"mod1", "mod2", "mod3", "mod4"} + # Create a circular dependency + mock_integration(hass, MockModule("mod1", ["mod4"])) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, "mod_3", mod_3, set(), set()) + await loader._async_component_dependencies(hass, mod_4) - # Depend on non-existing component - mod_1 = mock_integration(hass, MockModule("mod1", ["nonexisting"])) - - with pytest.raises(loader.IntegrationNotFound): - await loader._async_component_dependencies(hass, "mod_1", mod_1, set(), set()) + # Create a different circular dependency + mock_integration(hass, MockModule("mod1", ["mod3"])) + with pytest.raises(loader.CircularDependency): + await loader._async_component_dependencies(hass, mod_4) - # Having an after dependency 2 deps down that is circular - mod_1 = mock_integration( - hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod_3"]}) + # Create a circular after_dependency + mock_integration( + hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) ) + with pytest.raises(loader.CircularDependency): + await loader._async_component_dependencies(hass, mod_4) + # Create a different circular after_dependency + mock_integration( + hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod3"]}) + ) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, "mod_3", mod_3, set(), set()) + await loader._async_component_dependencies(hass, mod_4) + + +async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: + """Test if we can detect nonexistent dependencies of components.""" + mod_1 = mock_integration(hass, MockModule("mod1", ["nonexistent"])) + with pytest.raises(loader.IntegrationNotFound): + await loader._async_component_dependencies(hass, mod_1) def test_component_loader(hass: HomeAssistant) -> None: diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index ebcc9cec5260c0..76394b42491cf0 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -12,12 +12,19 @@ async def test_request_json() -> None: async def test_request_text() -> None: - """Test a JSON request.""" + """Test bytes in request.""" request = aiohttp.MockRequest(b"hello", status=201, mock_source="test") + assert request.body_exists assert request.status == 201 assert await request.text() == "hello" +async def test_request_body_exists() -> None: + """Test body exists.""" + request = aiohttp.MockRequest(b"", mock_source="test") + assert not request.body_exists + + async def test_request_post_query() -> None: """Test a JSON request.""" request = aiohttp.MockRequest(