diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 03c38c60a10..ce89d8c2b10 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -116,7 +116,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -197,7 +197,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2025.02.0 + uses: home-assistant/builder@2025.03.0 with: args: | $BUILD_ARGS \ @@ -263,7 +263,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2025.02.0 + uses: home-assistant/builder@2025.03.0 with: args: | $BUILD_ARGS \ @@ -457,7 +457,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b1606568b5..6fc1fdbca1c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 12 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.4" + HA_SHORT_VERSION: "2025.5" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version @@ -249,7 +249,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -294,7 +294,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -334,7 +334,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -374,7 +374,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -484,7 +484,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -587,7 +587,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -653,7 +653,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Dependency review - uses: actions/dependency-review-action@v4.5.0 + uses: actions/dependency-review-action@v4.6.0 with: license-check: false # We use our own license audit checks @@ -677,7 +677,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -720,7 +720,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -767,7 +767,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -812,7 +812,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -889,7 +889,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -949,7 +949,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1074,7 +1074,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1208,7 +1208,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1359,7 +1359,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f4d4144243c..bd072752d16 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.12 + uses: github/codeql-action/init@v3.28.13 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.12 + uses: github/codeql-action/analyze@v3.28.13 with: category: "/language:python" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 619d83aef51..0b6abe8fe2c 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index cdf0c07cccf..d27a62bab80 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -159,7 +159,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2025.02.0 + uses: home-assistant/wheels@2025.03.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -219,7 +219,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2025.02.0 + uses: home-assistant/wheels@2025.03.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 diff --git a/.strict-typing b/.strict-typing index 0e00c2e9e07..3e8ad0ddbaf 100644 --- a/.strict-typing +++ b/.strict-typing @@ -119,6 +119,7 @@ homeassistant.components.bluetooth_adapters.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* homeassistant.components.bond.* +homeassistant.components.bosch_alarm.* homeassistant.components.braviatv.* homeassistant.components.bring.* homeassistant.components.brother.* @@ -363,6 +364,7 @@ homeassistant.components.notify.* homeassistant.components.notion.* homeassistant.components.number.* homeassistant.components.nut.* +homeassistant.components.ohme.* homeassistant.components.onboarding.* homeassistant.components.oncue.* homeassistant.components.onedrive.* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b699ed44b96..09c1d374299 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Run Home Assistant Core", "type": "shell", - "command": "hass -c ./config", + "command": "${command:python.interpreterPath} -m homeassistant -c ./config", "group": "test", "presentation": { "reveal": "always", diff --git a/CODEOWNERS b/CODEOWNERS index 1835e6d0be4..8afd3bab028 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -216,6 +216,8 @@ build.json @home-assistant/supervisor /tests/components/bmw_connected_drive/ @gerard33 @rikroe /homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto /tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto +/homeassistant/components/bosch_alarm/ @mag1024 @sanjay900 +/tests/components/bosch_alarm/ @mag1024 @sanjay900 /homeassistant/components/bosch_shc/ @tschamm /tests/components/bosch_shc/ @tschamm /homeassistant/components/braviatv/ @bieniu @Drafteed @@ -1183,6 +1185,8 @@ build.json @home-assistant/supervisor /tests/components/prusalink/ @balloob /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 +/homeassistant/components/pterodactyl/ @elmurato +/tests/components/pterodactyl/ @elmurato /homeassistant/components/pure_energie/ @klaasnicolaas /tests/components/pure_energie/ @klaasnicolaas /homeassistant/components/purpleair/ @bachya @@ -1476,8 +1480,6 @@ build.json @home-assistant/supervisor /tests/components/suez_water/ @ooii @jb101010-2 /homeassistant/components/sun/ @Swamp-Ig /tests/components/sun/ @Swamp-Ig -/homeassistant/components/sunweg/ @rokam -/tests/components/sunweg/ @rokam /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen diff --git a/Dockerfile b/Dockerfile index 2efb9d59a44..0a74e0a3aac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ RUN \ && go2rtc --version # Install uv -RUN pip3 install uv==0.6.8 +RUN pip3 install uv==0.6.10 WORKDIR /usr/src diff --git a/build.yaml b/build.yaml index cd54e410493..87dad1bf5ef 100644 --- a/build.yaml +++ b/build.yaml @@ -19,4 +19,4 @@ labels: org.opencontainers.image.authors: The Home Assistant Authors org.opencontainers.image.url: https://www.home-assistant.io/ org.opencontainers.image.documentation: https://www.home-assistant.io/docs/ - org.opencontainers.image.licenses: Apache License 2.0 + org.opencontainers.image.licenses: Apache-2.0 diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index d224b0b151d..eb81268434b 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -178,6 +178,15 @@ _BLOCKING_CALLS: tuple[BlockingCall, ...] = ( strict_core=False, skip_for_tests=True, ), + BlockingCall( + original_func=SSLContext.set_default_verify_paths, + object=SSLContext, + function="set_default_verify_paths", + check_allowed=None, + strict=False, + strict_core=False, + skip_for_tests=True, + ), BlockingCall( original_func=Path.open, object=Path, diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 02a3b8c8fcc..962c7871028 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -859,8 +859,14 @@ async def _async_set_up_integrations( integrations, all_integrations = await _async_resolve_domains_and_preload( hass, config ) - all_domains = set(all_integrations) - domains = set(integrations) + # Detect all cycles + integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, all_integrations.values(), set(all_integrations) + ) + ) + all_domains = set(integrations_after_dependencies) + domains = set(integrations) & all_domains _LOGGER.info( "Domains to be set up: %s | %s", @@ -868,6 +874,8 @@ async def _async_set_up_integrations( all_domains - domains, ) + async_set_domains_to_be_loaded(hass, all_domains) + # Initialize recorder if "recorder" in all_domains: recorder.async_initialize_recorder(hass) @@ -900,24 +908,12 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered = { dep for domain in stage_domains - for dep in all_integrations[domain].all_dependencies + for dep in integrations_after_dependencies[domain] if dep not in stage_domains } stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components stage_all_domains = stage_domains | stage_dep_domains - stage_all_integrations = { - domain: all_integrations[domain] for domain in stage_all_domains - } - # Detect all cycles - stage_integrations_after_dependencies = ( - await loader.resolve_integrations_after_dependencies( - hass, stage_all_integrations.values(), stage_all_domains - ) - ) - stage_all_domains = set(stage_integrations_after_dependencies) - stage_domains &= stage_all_domains - stage_dep_domains &= stage_all_domains _LOGGER.info( "Setting up stage %s: %s | %s\nDependencies: %s | %s", @@ -928,8 +924,6 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered - stage_dep_domains, ) - async_set_domains_to_be_loaded(hass, stage_all_domains) - if timeout is None: await _async_setup_multi_components(hass, stage_all_domains, config) continue diff --git a/homeassistant/brands/bosch.json b/homeassistant/brands/bosch.json new file mode 100644 index 00000000000..090cc2af7c3 --- /dev/null +++ b/homeassistant/brands/bosch.json @@ -0,0 +1,5 @@ +{ + "domain": "bosch", + "name": "Bosch", + "integrations": ["bosch_alarm", "bosch_shc", "home_connect"] +} diff --git a/homeassistant/brands/eve.json b/homeassistant/brands/eve.json new file mode 100644 index 00000000000..f27c8b3d849 --- /dev/null +++ b/homeassistant/brands/eve.json @@ -0,0 +1,5 @@ +{ + "domain": "eve", + "name": "Eve", + "iot_standards": ["matter"] +} diff --git a/homeassistant/brands/motionblinds.json b/homeassistant/brands/motionblinds.json index 67013e75966..5a48b573b4d 100644 --- a/homeassistant/brands/motionblinds.json +++ b/homeassistant/brands/motionblinds.json @@ -1,5 +1,6 @@ { "domain": "motionblinds", "name": "Motionblinds", - "integrations": ["motion_blinds", "motionblinds_ble"] + "integrations": ["motion_blinds", "motionblinds_ble"], + "iot_standards": ["matter"] } diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index e1a71c5e1a5..e81ef782d98 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -72,10 +72,10 @@ "level": { "name": "Level", "state": { - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "very_high": "Very high" + "very_high": "[%key:common::state::very_high%]" } } } @@ -89,10 +89,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -123,10 +123,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -167,10 +167,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -181,10 +181,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -195,10 +195,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } diff --git a/homeassistant/components/adax/strings.json b/homeassistant/components/adax/strings.json index 6157b7dfc91..9ba497a9aca 100644 --- a/homeassistant/components/adax/strings.json +++ b/homeassistant/components/adax/strings.json @@ -5,14 +5,14 @@ "data": { "connection_type": "Select connection type" }, - "description": "Select connection type. Local requires heaters with bluetooth" + "description": "Select connection type. Local requires heaters with Bluetooth" }, "local": { "data": { "wifi_ssid": "Wi-Fi SSID", - "wifi_pswd": "Wi-Fi Password" + "wifi_pswd": "Wi-Fi password" }, - "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue led starts blinking before pressing Submit. Configuring heater might take some minutes." + "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue LED starts blinking before pressing Submit. Configuring heater might take some minutes." }, "cloud": { "data": { diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index 4cf3a6a34ea..cef4db57358 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -11,7 +11,7 @@ } }, "discovery_confirm": { - "description": "Do you want to setup {model}?" + "description": "Do you want to set up {model}?" } }, "abort": { @@ -68,8 +68,8 @@ "led_bar_mode": { "name": "LED bar mode", "state": { - "off": "Off", - "co2": "Carbon dioxide", + "off": "[%key:common::state::off%]", + "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "pm": "Particulate matter" } }, @@ -143,8 +143,8 @@ "led_bar_mode": { "name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]", "state": { - "off": "[%key:component::airgradient::entity::select::led_bar_mode::state::off%]", - "co2": "[%key:component::airgradient::entity::select::led_bar_mode::state::co2%]", + "off": "[%key:common::state::off%]", + "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]" } }, diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index ee5bf4a1dd7..1e73bc7551e 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -8,7 +8,7 @@ from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from pyairnow import WebServiceAPI from pyairnow.conv import aqi_to_concentration -from pyairnow.errors import AirNowError +from pyairnow.errors import AirNowError, InvalidJsonError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -79,7 +79,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): distance=self.distance, ) - except (AirNowError, ClientConnectorError) as error: + except (AirNowError, ClientConnectorError, InvalidJsonError) as error: raise UpdateFailed(error) from error if not obs: diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index d5fb22106f9..a69f67948cb 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -7,7 +7,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", - "radius": "Station Radius (miles; optional)" + "radius": "Station radius (miles; optional)" } } }, @@ -25,7 +25,7 @@ "step": { "init": { "data": { - "radius": "Station Radius (miles)" + "radius": "Station radius (miles)" } } } diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 26b944467e6..9c16975a3ab 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -91,7 +91,7 @@ "name": "Hydrogen fluoride" }, "health_index": { - "name": "Health Index" + "name": "Health index" }, "absolute_humidity": { "name": "Absolute humidity" @@ -112,10 +112,10 @@ "name": "Oxygen" }, "performance_index": { - "name": "Performance Index" + "name": "Performance index" }, "hydrogen_phosphide": { - "name": "Hydrogen Phosphide" + "name": "Hydrogen phosphide" }, "relative_pressure": { "name": "Relative pressure" @@ -127,22 +127,22 @@ "name": "Refrigerant" }, "silicon_hydride": { - "name": "Silicon Hydride" + "name": "Silicon hydride" }, "noise": { "name": "Noise" }, "maximum_noise": { - "name": "Noise (Maximum)" + "name": "Noise (maximum)" }, "radon": { "name": "Radon" }, "industrial_volatile_organic_compounds": { - "name": "VOCs (Industrial)" + "name": "VOCs (industrial)" }, "virus_index": { - "name": "Virus Index" + "name": "Virus index" } } } diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index 3e7b659bff1..2d32fa6e7df 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -102,7 +102,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._get_device_data(discovery_info) except AirthingsDeviceUpdateError: return self.async_abort(reason="cannot_connect") - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unknown error occurred") return self.async_abort(reason="unknown") name = get_name(device) @@ -160,7 +161,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._get_device_data(discovery_info) except AirthingsDeviceUpdateError: return self.async_abort(reason="cannot_connect") - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unknown error occurred") return self.async_abort(reason="unknown") name = get_name(device) self._discovered_devices[address] = Discovery(name, discovery_info, device) diff --git a/homeassistant/components/airtouch5/config_flow.py b/homeassistant/components/airtouch5/config_flow.py index d96aaed96b7..38c85e45fb8 100644 --- a/homeassistant/components/airtouch5/config_flow.py +++ b/homeassistant/components/airtouch5/config_flow.py @@ -32,7 +32,8 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN): client = Airtouch5SimpleClient(user_input[CONF_HOST]) try: await client.test_connection() - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors = {"base": "cannot_connect"} else: await self.async_set_unique_id(user_input[CONF_HOST]) diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json index 148b1368a19..9d53be4dee7 100644 --- a/homeassistant/components/airvisual/strings.json +++ b/homeassistant/components/airvisual/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "geography_by_coords": { - "title": "Configure a Geography", + "title": "Configure a geography", "description": "Use the AirVisual cloud API to monitor a latitude/longitude.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", @@ -16,8 +16,8 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "city": "City", - "country": "Country", - "state": "State" + "state": "State", + "country": "[%key:common::config_flow::data::country%]" } }, "reauth_confirm": { @@ -56,12 +56,12 @@ "sensor": { "pollutant_label": { "state": { - "co": "Carbon Monoxide", - "n2": "Nitrogen Dioxide", - "o3": "Ozone", - "p1": "PM10", - "p2": "PM2.5", - "s2": "Sulfur Dioxide" + "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "o3": "[%key:component::sensor::entity_component::ozone::name%]", + "p1": "[%key:component::sensor::entity_component::pm10::name%]", + "p2": "[%key:component::sensor::entity_component::pm25::name%]", + "s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" } }, "pollutant_level": { diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 95ed9d200f4..1b636de0a47 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.9"] + "requirements": ["aioairzone==1.0.0"] } diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index f76eb1466a3..66657836b74 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -9,6 +9,8 @@ from aioairzone.const import ( AZD_HUMIDITY, AZD_TEMP, AZD_TEMP_UNIT, + AZD_THERMOSTAT_BATTERY, + AZD_THERMOSTAT_SIGNAL, AZD_WEBSERVER, AZD_WIFI_RSSI, AZD_ZONES, @@ -73,6 +75,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + device_class=SensorDeviceClass.BATTERY, + key=AZD_THERMOSTAT_BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_THERMOSTAT_SIGNAL, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="thermostat_signal", + ), ) diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index cd313b821aa..c7d9701aa83 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -76,6 +76,9 @@ "sensor": { "rssi": { "name": "RSSI" + }, + "thermostat_signal": { + "name": "Signal strength" } } } diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 0e21e57ec52..3b6f94df57c 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_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.10"] + "requirements": ["aioairzone-cloud==0.6.11"] } diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json index 6e0f9adcd66..5dbd4384386 100644 --- a/homeassistant/components/airzone_cloud/strings.json +++ b/homeassistant/components/airzone_cloud/strings.json @@ -32,8 +32,8 @@ "air_quality": { "name": "Air Quality mode", "state": { - "off": "Off", - "on": "On", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", "auto": "Auto" } }, diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index e70055c20b1..897037987a7 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1438,7 +1438,7 @@ class AlexaModeController(AlexaCapability): # Fan preset_mode if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": mode = self.entity.attributes.get(fan.ATTR_PRESET_MODE, None) - if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, None): + if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, ()): return f"{fan.ATTR_PRESET_MODE}.{mode}" # Humidifier mode diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json index e7fbf8edc74..f684292d9a2 100644 --- a/homeassistant/components/amazon_polly/manifest.json +++ b/homeassistant/components/amazon_polly/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], "quality_scale": "legacy", - "requirements": ["boto3==1.34.131"] + "requirements": ["boto3==1.37.1"] } diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py index 9ec6db6ff45..b96da9863a1 100644 --- a/homeassistant/components/ambient_network/sensor.py +++ b/homeassistant/components/ambient_network/sensor.py @@ -240,6 +240,7 @@ SENSOR_DESCRIPTIONS = ( suggested_display_precision=0, entity_registry_enabled_default=False, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key=TYPE_WINDGUSTMPH, diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 730b798bd15..1b4334774d4 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -609,6 +609,7 @@ SENSOR_DESCRIPTIONS = ( translation_key="wind_direction", native_unit_of_measurement=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key=TYPE_WINDDIR_AVG10M, diff --git a/homeassistant/components/android_ip_webcam/manifest.json b/homeassistant/components/android_ip_webcam/manifest.json index 57af567ec51..d7a9f8ad97a 100644 --- a/homeassistant/components/android_ip_webcam/manifest.json +++ b/homeassistant/components/android_ip_webcam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/android_ip_webcam", "iot_class": "local_polling", - "requirements": ["pydroid-ipcam==2.0.0"] + "requirements": ["pydroid-ipcam==3.0.0"] } diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 1c45e825359..89cc0fc3965 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], - "requirements": ["androidtvremote2==0.2.0"], + "requirements": ["androidtvremote2==0.2.1"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/anova/config_flow.py b/homeassistant/components/anova/config_flow.py index bc4723b1dba..f382606baba 100644 --- a/homeassistant/components/anova/config_flow.py +++ b/homeassistant/components/anova/config_flow.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from anova_wifi import AnovaApi, InvalidLogin import voluptuous as vol @@ -11,8 +13,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) -class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): + +class AnovaConfigFlow(ConfigFlow, domain=DOMAIN): """Sets up a config flow for Anova.""" VERSION = 1 @@ -35,7 +39,8 @@ class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): await api.authenticate() except InvalidLogin: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry( diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index 93102ac1393..fb5df9ec390 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -57,7 +57,7 @@ "name": "Status date" }, "dip_switch_settings": { - "name": "Dip switch settings" + "name": "DIP switch settings" }, "low_battery_signal": { "name": "Low battery signal" diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 76c4681a30d..b026da33231 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -20,6 +20,7 @@ import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_IGNORE, + SOURCE_REAUTH, SOURCE_ZEROCONF, ConfigEntry, ConfigFlow, @@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): CONF_IDENTIFIERS: list(combined_identifiers), }, ) - if entry.source != SOURCE_IGNORE: + # Don't reload ignored entries or in the middle of reauth, + # e.g. if the user is entering a new PIN + if entry.source != SOURCE_IGNORE and self.source != SOURCE_REAUTH: self.hass.config_entries.async_schedule_reload(entry.entry_id) if not allow_exist: raise DeviceAlreadyConfigured diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json index 577de8ae88d..b40460dd61b 100644 --- a/homeassistant/components/aprilaire/manifest.json +++ b/homeassistant/components/aprilaire/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pyaprilaire"], - "requirements": ["pyaprilaire==0.7.7"] + "requirements": ["pyaprilaire==0.8.1"] } diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py index ca423055176..f7f1039b8a4 100644 --- a/homeassistant/components/apsystems/coordinator.py +++ b/homeassistant/components/apsystems/coordinator.py @@ -43,6 +43,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): config_entry: ApSystemsConfigEntry device_version: str + battery_system: bool def __init__( self, @@ -68,6 +69,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): self.api.max_power = device_info.maxPower self.api.min_power = device_info.minPower self.device_version = device_info.devVer + self.battery_system = device_info.isBatterySystem async def _async_update_data(self) -> ApSystemsSensorData: try: diff --git a/homeassistant/components/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json index a58530b05e2..934a155c500 100644 --- a/homeassistant/components/apsystems/manifest.json +++ b/homeassistant/components/apsystems/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/apsystems", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["apsystems-ez1==2.4.0"] + "requirements": ["apsystems-ez1==2.5.0"] } diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py index e1017f95448..5451f2885fe 100644 --- a/homeassistant/components/apsystems/switch.py +++ b/homeassistant/components/apsystems/switch.py @@ -36,6 +36,8 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity): super().__init__(data) self._api = data.coordinator.api self._attr_unique_id = f"{data.device_id}_inverter_status" + if data.coordinator.battery_system: + self._attr_available = False async def async_update(self) -> None: """Update switch status and availability.""" diff --git a/homeassistant/components/aquacell/config_flow.py b/homeassistant/components/aquacell/config_flow.py index 1ee89035d93..277cb742486 100644 --- a/homeassistant/components/aquacell/config_flow.py +++ b/homeassistant/components/aquacell/config_flow.py @@ -60,7 +60,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except AuthenticationFailed: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/aquacell/strings.json b/homeassistant/components/aquacell/strings.json index 53304d04804..e07adf3c199 100644 --- a/homeassistant/components/aquacell/strings.json +++ b/homeassistant/components/aquacell/strings.json @@ -36,9 +36,9 @@ "wi_fi_strength": { "name": "Wi-Fi strength", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index a31156bbba6..4cc4feed2d4 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -6,7 +6,11 @@ import logging from typing import Any from homeassistant.components import mqtt -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -98,6 +102,7 @@ def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | DEGREE, "mdi:compass", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), ] return None @@ -178,6 +183,7 @@ class ArwnSensor(SensorEntity): units: str, icon: str | None = None, device_class: SensorDeviceClass | None = None, + state_class: SensorStateClass | None = None, ) -> None: """Initialize the sensor.""" self.entity_id = _slug(name) @@ -188,6 +194,7 @@ class ArwnSensor(SensorEntity): self._attr_native_unit_of_measurement = units self._attr_icon = icon self._attr_device_class = device_class + self._attr_state_class = state_class def set_event(self, event: dict[str, Any]) -> None: """Update the sensor with the most recent event.""" diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 42bb2d4ced8..a205db4e615 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -125,7 +125,7 @@ SAVE_DELAY = 10 @callback def _async_local_fallback_intent_filter(result: RecognizeResult) -> bool: """Filter out intents that are not local fallback.""" - return result.intent.name in (intent.INTENT_GET_STATE, intent.INTENT_NEVERMIND) + return result.intent.name in (intent.INTENT_GET_STATE) @callback diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 038ff517264..3338f223bc9 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -1,9 +1,11 @@ """Base class for assist satellite entities.""" import logging +from pathlib import Path import voluptuous as vol +from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -15,6 +17,8 @@ from .const import ( CONNECTION_TEST_DATA, DATA_COMPONENT, DOMAIN, + PREANNOUNCE_FILENAME, + PREANNOUNCE_URL, AssistSatelliteEntityFeature, ) from .entity import ( @@ -56,6 +60,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("message"): str, vol.Optional("media_id"): str, + vol.Optional("preannounce"): bool, + vol.Optional("preannounce_media_id"): str, } ), cv.has_at_least_one_key("message", "media_id"), @@ -70,6 +76,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("start_message"): str, vol.Optional("start_media_id"): str, + vol.Optional("preannounce"): bool, + vol.Optional("preannounce_media_id"): str, vol.Optional("extra_system_prompt"): str, } ), @@ -82,6 +90,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) + # Default preannounce sound + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + PREANNOUNCE_URL, str(Path(__file__).parent / PREANNOUNCE_FILENAME) + ) + ] + ) + return True diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index f7ac7e524b4..7fca88f3b12 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -20,6 +20,9 @@ CONNECTION_TEST_DATA: HassKey[dict[str, asyncio.Event]] = HassKey( f"{DOMAIN}_connection_tests" ) +PREANNOUNCE_FILENAME = "preannounce.mp3" +PREANNOUNCE_URL = f"/api/assist_satellite/static/{PREANNOUNCE_FILENAME}" + class AssistSatelliteEntityFeature(IntFlag): """Supported features of Assist satellite entity.""" diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 3db38a23889..dc20c7650d7 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -28,7 +28,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, entity from homeassistant.helpers.entity import EntityDescription -from .const import AssistSatelliteEntityFeature +from .const import PREANNOUNCE_URL, AssistSatelliteEntityFeature from .errors import AssistSatelliteError, SatelliteBusyError _LOGGER = logging.getLogger(__name__) @@ -101,6 +101,9 @@ class AssistSatelliteAnnouncement: media_id_source: Literal["url", "media_id", "tts"] """Source of the media ID.""" + preannounce_media_id: str | None = None + """Media ID to be played before announcement.""" + class AssistSatelliteEntity(entity.Entity): """Entity encapsulating the state and functionality of an Assist satellite.""" @@ -177,6 +180,8 @@ class AssistSatelliteEntity(entity.Entity): self, message: str | None = None, media_id: str | None = None, + preannounce: bool = True, + preannounce_media_id: str = PREANNOUNCE_URL, ) -> None: """Play and show an announcement on the satellite. @@ -186,6 +191,9 @@ class AssistSatelliteEntity(entity.Entity): If media_id is provided, it is played directly. It is possible to omit the message and the satellite will not show any text. + If preannounce is True, a sound is played before the announcement. + If preannounce_media_id is provided, it overrides the default sound. + Calls async_announce with message and media id. """ await self._cancel_running_pipeline() @@ -193,7 +201,11 @@ class AssistSatelliteEntity(entity.Entity): if message is None: message = "" - announcement = await self._resolve_announcement_media_id(message, media_id) + announcement = await self._resolve_announcement_media_id( + message, + media_id, + preannounce_media_id=preannounce_media_id if preannounce else None, + ) if self._is_announcing: raise SatelliteBusyError @@ -220,6 +232,8 @@ class AssistSatelliteEntity(entity.Entity): start_message: str | None = None, start_media_id: str | None = None, extra_system_prompt: str | None = None, + preannounce: bool = True, + preannounce_media_id: str = PREANNOUNCE_URL, ) -> None: """Start a conversation from the satellite. @@ -229,6 +243,9 @@ class AssistSatelliteEntity(entity.Entity): If start_media_id is provided, it is played directly. It is possible to omit the message and the satellite will not show any text. + If preannounce is True, a sound is played before the start message or media. + If preannounce_media_id is provided, it overrides the default sound. + Calls async_start_conversation. """ await self._cancel_running_pipeline() @@ -244,13 +261,17 @@ class AssistSatelliteEntity(entity.Entity): start_message = "" announcement = await self._resolve_announcement_media_id( - start_message, start_media_id + start_message, + start_media_id, + preannounce_media_id=preannounce_media_id if preannounce else None, ) if self._is_announcing: raise SatelliteBusyError self._is_announcing = True + self._set_state(AssistSatelliteState.RESPONDING) + # Provide our start info to the LLM so it understands context of incoming message if extra_system_prompt is not None: self._extra_system_prompt = extra_system_prompt @@ -280,6 +301,7 @@ class AssistSatelliteEntity(entity.Entity): raise finally: self._is_announcing = False + self._set_state(AssistSatelliteState.IDLE) async def async_start_conversation( self, start_announcement: AssistSatelliteAnnouncement @@ -470,7 +492,10 @@ class AssistSatelliteEntity(entity.Entity): return vad.VadSensitivity.to_seconds(vad_sensitivity) async def _resolve_announcement_media_id( - self, message: str, media_id: str | None + self, + message: str, + media_id: str | None, + preannounce_media_id: str | None = None, ) -> AssistSatelliteAnnouncement: """Resolve the media ID.""" media_id_source: Literal["url", "media_id", "tts"] | None = None @@ -478,7 +503,6 @@ class AssistSatelliteEntity(entity.Entity): if media_id: original_media_id = media_id - else: media_id_source = "tts" # Synthesize audio and get URL @@ -530,10 +554,26 @@ class AssistSatelliteEntity(entity.Entity): # Resolve to full URL media_id = async_process_play_media_url(self.hass, media_id) + # Resolve preannounce media id + if preannounce_media_id: + if media_source.is_media_source_id(preannounce_media_id): + preannounce_media = await media_source.async_resolve_media( + self.hass, + preannounce_media_id, + None, + ) + preannounce_media_id = preannounce_media.url + + # Resolve to full URL + preannounce_media_id = async_process_play_media_url( + self.hass, preannounce_media_id + ) + return AssistSatelliteAnnouncement( message=message, media_id=media_id, original_media_id=original_media_id, tts_token=tts_token, media_id_source=media_id_source, + preannounce_media_id=preannounce_media_id, ) diff --git a/homeassistant/components/assist_satellite/preannounce.mp3 b/homeassistant/components/assist_satellite/preannounce.mp3 new file mode 100644 index 00000000000..6e2fa0aba3e Binary files /dev/null and b/homeassistant/components/assist_satellite/preannounce.mp3 differ diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 89a20ada6f3..d88710c4c4e 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -8,12 +8,22 @@ announce: message: required: false example: "Time to wake up!" + default: "" selector: text: media_id: required: false selector: text: + preannounce: + required: false + default: true + selector: + boolean: + preannounce_media_id: + required: false + selector: + text: start_conversation: target: entity: @@ -24,6 +34,7 @@ start_conversation: start_message: required: false example: "You left the lights on in the living room. Turn them off?" + default: "" selector: text: start_media_id: @@ -34,3 +45,12 @@ start_conversation: required: false selector: text: + preannounce: + required: false + default: true + selector: + boolean: + preannounce_media_id: + required: false + selector: + text: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index fa2dc984ab7..b69711c7106 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -23,6 +23,14 @@ "media_id": { "name": "Media ID", "description": "The media ID to announce instead of using text-to-speech." + }, + "preannounce": { + "name": "Preannounce", + "description": "Play a sound before the announcement." + }, + "preannounce_media_id": { + "name": "Preannounce media ID", + "description": "Custom media ID to play before the announcement." } } }, @@ -41,6 +49,14 @@ "extra_system_prompt": { "name": "Extra system prompt", "description": "Provide background information to the AI about the request." + }, + "preannounce": { + "name": "Preannounce", + "description": "Play a sound before the start message or media." + }, + "preannounce_media_id": { + "name": "Preannounce media ID", + "description": "Custom media ID to play before the start message or media." } } } diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 4fc1708b866..6f8b3d723ad 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -198,7 +198,8 @@ async def websocket_test_connection( hass.async_create_background_task( satellite.async_internal_announce( - media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}" + media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}", + preannounce=False, ), f"assist_satellite_connection_test_{msg['entity_id']}", ) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index 9d50f50c7e9..cac37c0cfd0 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -66,28 +66,28 @@ "name": "Upload" }, "load_avg_1m": { - "name": "Average load (1m)" + "name": "Average load (1 min)" }, "load_avg_5m": { - "name": "Average load (5m)" + "name": "Average load (5 min)" }, "load_avg_15m": { - "name": "Average load (15m)" + "name": "Average load (15 min)" }, "24ghz_temperature": { - "name": "2.4GHz Temperature" + "name": "2.4GHz temperature" }, "5ghz_temperature": { - "name": "5GHz Temperature" + "name": "5GHz temperature" }, "cpu_temperature": { - "name": "CPU Temperature" + "name": "CPU temperature" }, "5ghz_2_temperature": { - "name": "5GHz Temperature (Radio 2)" + "name": "5GHz temperature (Radio 2)" }, "6ghz_temperature": { - "name": "6GHz Temperature" + "name": "6GHz temperature" }, "cpu_usage": { "name": "CPU usage" diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index 12149e4388a..92ae37c857b 100644 --- a/homeassistant/components/aws/manifest.json +++ b/homeassistant/components/aws/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["aiobotocore", "botocore"], "quality_scale": "legacy", - "requirements": ["aiobotocore==2.13.1", "botocore==1.34.131"] + "requirements": ["aiobotocore==2.21.1", "botocore==1.37.1"] } diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json index f5fe5cd06a7..611a8b9a758 100644 --- a/homeassistant/components/azure_devops/strings.json +++ b/homeassistant/components/azure_devops/strings.json @@ -14,7 +14,7 @@ "personal_access_token": "Personal Access Token (PAT)" }, "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", - "title": "Add Azure DevOps Project" + "title": "Add Azure DevOps project" }, "reauth_confirm": { "data": { @@ -32,7 +32,7 @@ "entity": { "sensor": { "build_id": { - "name": "{definition_name} latest build id" + "name": "{definition_name} latest build ID" }, "finish_time": { "name": "{definition_name} latest build finish time" @@ -59,7 +59,7 @@ "name": "{definition_name} latest build start time" }, "url": { - "name": "{definition_name} latest build url" + "name": "{definition_name} latest build URL" }, "work_item_count": { "name": "{item_type} {item_state} work items" @@ -68,7 +68,7 @@ }, "exceptions": { "authentication_failed": { - "message": "Could not authorize with Azure DevOps for {title}. You will need to update your personal access token." + "message": "Could not authorize with Azure DevOps for {title}. You will need to update your Personal Access Token." } } } diff --git a/homeassistant/components/azure_storage/backup.py b/homeassistant/components/azure_storage/backup.py index 4d897126d3d..4a9254213dc 100644 --- a/homeassistant/components/azure_storage/backup.py +++ b/homeassistant/components/azure_storage/backup.py @@ -175,7 +175,8 @@ class AzureStorageBackupAgent(BackupAgent): """Find a blob by backup id.""" async for blob in self._client.list_blobs(include="metadata"): if ( - backup_id == blob.metadata.get("backup_id", "") + blob.metadata is not None + and backup_id == blob.metadata.get("backup_id", "") and blob.metadata.get("metadata_version") == METADATA_VERSION ): return blob diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index d9d1c3cc2fe..124ce8b872c 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -1,7 +1,9 @@ """The Backup integration.""" +from homeassistant.config_entries import SOURCE_SYSTEM +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType @@ -18,10 +20,12 @@ from .agent import ( ) from .config import BackupConfig, CreateBackupParametersDict from .const import DATA_MANAGER, DOMAIN +from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator from .http import async_register_http_views from .manager import ( BackupManager, BackupManagerError, + BackupPlatformEvent, BackupPlatformProtocol, BackupReaderWriter, BackupReaderWriterError, @@ -52,6 +56,7 @@ __all__ = [ "BackupConfig", "BackupManagerError", "BackupNotFound", + "BackupPlatformEvent", "BackupPlatformProtocol", "BackupReaderWriter", "BackupReaderWriterError", @@ -74,6 +79,8 @@ __all__ = [ "suggested_filename_from_name_date", ] +PLATFORMS = [Platform.SENSOR] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -128,4 +135,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_http_views(hass) + discovery_flow.async_create_flow( + hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool: + """Set up a config entry.""" + backup_manager: BackupManager = hass.data[DATA_MANAGER] + coordinator = BackupDataUpdateCoordinator(hass, entry, backup_manager) + await coordinator.async_config_entry_first_refresh() + + entry.async_on_unload(coordinator.async_unsubscribe) + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/backup/config_flow.py b/homeassistant/components/backup/config_flow.py new file mode 100644 index 00000000000..ab1f884ea86 --- /dev/null +++ b/homeassistant/components/backup/config_flow.py @@ -0,0 +1,21 @@ +"""Config flow for Home Assistant Backup integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import DOMAIN + + +class BackupConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Home Assistant Backup.""" + + VERSION = 1 + + async def async_step_system( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + return self.async_create_entry(title="Backup", data={}) diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index c2070a37b2d..773deaef174 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -16,8 +16,8 @@ DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN) LOGGER = getLogger(__package__) EXCLUDE_FROM_BACKUP = [ - "__pycache__/*", - ".DS_Store", + "**/__pycache__/*", + "**/.DS_Store", ".HA_RESTORE", "*.db-shm", "*.log.*", diff --git a/homeassistant/components/backup/coordinator.py b/homeassistant/components/backup/coordinator.py new file mode 100644 index 00000000000..377f23567e0 --- /dev/null +++ b/homeassistant/components/backup/coordinator.py @@ -0,0 +1,81 @@ +"""Coordinator for Home Assistant Backup integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.backup import ( + async_subscribe_events, + async_subscribe_platform_events, +) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER +from .manager import ( + BackupManager, + BackupManagerState, + BackupPlatformEvent, + ManagerStateEvent, +) + +type BackupConfigEntry = ConfigEntry[BackupDataUpdateCoordinator] + + +@dataclass +class BackupCoordinatorData: + """Class to hold backup data.""" + + backup_manager_state: BackupManagerState + last_successful_automatic_backup: datetime | None + next_scheduled_automatic_backup: datetime | None + + +class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]): + """Class to retrieve backup status.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + backup_manager: BackupManager, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=None, + ) + self.unsubscribe: list[Callable[[], None]] = [ + async_subscribe_events(hass, self._on_event), + async_subscribe_platform_events(hass, self._on_event), + ] + + self.backup_manager = backup_manager + + @callback + def _on_event(self, event: ManagerStateEvent | BackupPlatformEvent) -> None: + """Handle new event.""" + LOGGER.debug("Received backup event: %s", event) + self.config_entry.async_create_task(self.hass, self.async_refresh()) + + async def _async_update_data(self) -> BackupCoordinatorData: + """Update backup manager data.""" + return BackupCoordinatorData( + self.backup_manager.state, + self.backup_manager.config.data.last_completed_automatic_backup, + self.backup_manager.config.data.schedule.next_automatic_backup, + ) + + @callback + def async_unsubscribe(self) -> None: + """Unsubscribe from events.""" + for unsub in self.unsubscribe: + unsub() diff --git a/homeassistant/components/backup/diagnostics.py b/homeassistant/components/backup/diagnostics.py new file mode 100644 index 00000000000..9c3e28bde5b --- /dev/null +++ b/homeassistant/components/backup/diagnostics.py @@ -0,0 +1,27 @@ +"""Diagnostics support for Home Assistant Backup integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .coordinator import BackupConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: BackupConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + return { + "backup_agents": [ + {"name": agent.name, "agent_id": agent.agent_id} + for agent in coordinator.backup_manager.backup_agents.values() + ], + "backup_config": async_redact_data( + coordinator.backup_manager.config.data.to_dict(), [CONF_PASSWORD] + ), + } diff --git a/homeassistant/components/backup/entity.py b/homeassistant/components/backup/entity.py new file mode 100644 index 00000000000..ff7c7889dc5 --- /dev/null +++ b/homeassistant/components/backup/entity.py @@ -0,0 +1,36 @@ +"""Base for backup entities.""" + +from __future__ import annotations + +from homeassistant.const import __version__ as HA_VERSION +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import BackupDataUpdateCoordinator + + +class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]): + """Base entity for backup manager.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BackupDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = entity_description.key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, "backup_manager")}, + manufacturer="Home Assistant", + model="Home Assistant Backup", + sw_version=HA_VERSION, + name="Backup", + entry_type=DeviceEntryType.SERVICE, + configuration_url="homeassistant://config/backup", + ) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 6dbe863185c..43a7be6db8d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -229,6 +229,13 @@ class RestoreBackupEvent(ManagerStateEvent): state: RestoreBackupState +@dataclass(frozen=True, kw_only=True, slots=True) +class BackupPlatformEvent: + """Backup platform class.""" + + domain: str + + @dataclass(frozen=True, kw_only=True, slots=True) class BlockedEvent(ManagerStateEvent): """Backup manager blocked, Home Assistant is starting.""" @@ -355,6 +362,9 @@ class BackupManager: self._backup_event_subscriptions = hass.data[ DATA_BACKUP ].backup_event_subscriptions + self._backup_platform_event_subscriptions = hass.data[ + DATA_BACKUP + ].backup_platform_event_subscriptions async def async_setup(self) -> None: """Set up the backup manager.""" @@ -465,6 +475,9 @@ class BackupManager: LOGGER.debug("%s platforms loaded in total", len(self.platforms)) LOGGER.debug("%s agents loaded in total", len(self.backup_agents)) LOGGER.debug("%s local agents loaded in total", len(self.local_backup_agents)) + event = BackupPlatformEvent(domain=integration_domain) + for subscription in self._backup_platform_event_subscriptions: + subscription(event) async def async_pre_backup_actions(self) -> None: """Perform pre backup actions.""" @@ -1713,7 +1726,9 @@ class CoreBackupReaderWriter(BackupReaderWriter): """Filter to filter excludes.""" for exclude in excludes: - if not path.match(exclude): + # The home assistant core configuration directory is added as "data" + # in the tar file, so we need to prefix that path to the filters. + if not path.full_match(f"data/{exclude}"): continue LOGGER.debug("Ignoring %s because of %s", path, exclude) return True diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index db0719983b1..3c7b1e5e014 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -5,8 +5,9 @@ "codeowners": ["@home-assistant/core"], "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/backup", - "integration_type": "system", + "integration_type": "service", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2025.2.1"] + "requirements": ["cronsim==2.6", "securetar==2025.2.1"], + "single_config_entry": true } diff --git a/homeassistant/components/backup/sensor.py b/homeassistant/components/backup/sensor.py new file mode 100644 index 00000000000..59e98ae7c2d --- /dev/null +++ b/homeassistant/components/backup/sensor.py @@ -0,0 +1,75 @@ +"""Sensor platform for Home Assistant Backup integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import BackupConfigEntry, BackupCoordinatorData +from .entity import BackupManagerEntity +from .manager import BackupManagerState + + +@dataclass(kw_only=True, frozen=True) +class BackupSensorEntityDescription(SensorEntityDescription): + """Description for Home Assistant Backup sensor entities.""" + + value_fn: Callable[[BackupCoordinatorData], str | datetime | None] + + +BACKUP_MANAGER_DESCRIPTIONS = ( + BackupSensorEntityDescription( + key="backup_manager_state", + translation_key="backup_manager_state", + device_class=SensorDeviceClass.ENUM, + options=[state.value for state in BackupManagerState], + value_fn=lambda data: data.backup_manager_state, + ), + BackupSensorEntityDescription( + key="next_scheduled_automatic_backup", + translation_key="next_scheduled_automatic_backup", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.next_scheduled_automatic_backup, + ), + BackupSensorEntityDescription( + key="last_successful_automatic_backup", + translation_key="last_successful_automatic_backup", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.last_successful_automatic_backup, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BackupConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Sensor set up for backup config entry.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + BackupManagerSensor(coordinator, description) + for description in BACKUP_MANAGER_DESCRIPTIONS + ) + + +class BackupManagerSensor(BackupManagerEntity, SensorEntity): + """Sensor to track backup manager state.""" + + entity_description: BackupSensorEntityDescription + + @property + def native_value(self) -> str | datetime | None: + """Return native value of entity.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index c3047d3a4ac..357bcdbb72f 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -22,5 +22,24 @@ "name": "Create automatic backup", "description": "Creates a new backup with automatic backup settings." } + }, + "entity": { + "sensor": { + "backup_manager_state": { + "name": "Backup Manager state", + "state": { + "idle": "[%key:common::state::idle%]", + "create_backup": "Creating a backup", + "receive_backup": "Receiving a backup", + "restore_backup": "Restoring a backup" + } + }, + "next_scheduled_automatic_backup": { + "name": "Next scheduled automatic backup" + }, + "last_successful_automatic_backup": { + "name": "Last successful automatic backup" + } + } } } diff --git a/homeassistant/components/balay/__init__.py b/homeassistant/components/balay/__init__.py new file mode 100644 index 00000000000..e7fa8bba86d --- /dev/null +++ b/homeassistant/components/balay/__init__.py @@ -0,0 +1 @@ +"""Balay virtual integration.""" diff --git a/homeassistant/components/balay/manifest.json b/homeassistant/components/balay/manifest.json new file mode 100644 index 00000000000..98e4f521c7a --- /dev/null +++ b/homeassistant/components/balay/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "balay", + "name": "Balay", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 784ce8533a8..8297e2e3b9f 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -103,8 +103,8 @@ "temperature_range": { "name": "Temperature range", "state": { - "low": "Low", - "high": "High" + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" } } }, diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index b86a6374f28..ea897ed1c49 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -124,15 +124,15 @@ "battery": { "name": "Battery", "state": { - "off": "Normal", - "on": "Low" + "off": "[%key:common::state::normal%]", + "on": "[%key:common::state::low%]" } }, "battery_charging": { "name": "Charging", "state": { "off": "Not charging", - "on": "Charging" + "on": "[%key:common::state::charging%]" } }, "carbon_monoxide": { @@ -145,7 +145,7 @@ "cold": { "name": "Cold", "state": { - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "off": "[%key:common::state::normal%]", "on": "Cold" } }, @@ -180,7 +180,7 @@ "heat": { "name": "Heat", "state": { - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "off": "[%key:common::state::normal%]", "on": "Hot" } }, diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 2e48d768a74..b90a4792f65 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -37,7 +37,7 @@ "vehicle_status": { "name": "Vehicle status", "state": { - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "vehicle_detected": "Detected", "ready": "Ready", "no_power": "No power", diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 135d1b5d27e..0addcc1daac 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -501,18 +501,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity return # presets and inputs might have the same name; presets have priority - url: str | None = None for input_ in self._inputs: if input_.text == source: - url = input_.url + await self._player.play_url(input_.url) + return for preset in self._presets: if preset.name == source: - url = preset.url + await self._player.load_preset(preset.id) + return - if url is None: - raise ServiceValidationError(f"Source {source} not found") - - await self._player.play_url(url) + raise ServiceValidationError(f"Source {source} not found") async def async_clear_playlist(self) -> None: """Clear players playlist.""" diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index a0679f8e842..d13411b62c4 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,8 +19,8 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", - "bluetooth-data-tools==1.26.1", - "dbus-fast==2.39.6", - "habluetooth==3.32.0" + "bluetooth-data-tools==1.27.0", + "dbus-fast==2.43.0", + "habluetooth==3.37.0" ] } diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 4b16b719d8d..bd9814476f5 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -6,7 +6,7 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "region": "ConnectedDrive Region" + "region": "ConnectedDrive region" }, "data_description": { "username": "The email address of your MyBMW/MINI Connected account.", @@ -113,10 +113,10 @@ }, "select": { "ac_limit": { - "name": "AC Charging Limit" + "name": "AC charging limit" }, "charging_mode": { - "name": "Charging Mode", + "name": "Charging mode", "state": { "immediate_charging": "Immediate charging", "delayed_charging": "Delayed charging", @@ -181,7 +181,7 @@ "cooling": "Cooling", "heating": "Heating", "inactive": "Inactive", - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "ventilation": "Ventilation" } }, diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 38abd63186a..ffa0098840c 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -91,11 +92,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered[CONF_ACCESS_TOKEN] = token try: - _, hub_name = await _validate_input(self.hass, self._discovered) + bond_id, hub_name = await _validate_input(self.hass, self._discovered) except InputValidationError: return + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._discovered[CONF_NAME] = hub_name + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by dhcp discovery.""" + host = discovery_info.ip + bond_id = discovery_info.hostname.partition("-")[2].upper() + await self.async_set_unique_id(bond_id) + return await self.async_step_any_discovery(bond_id, host) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: @@ -104,11 +116,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): host: str = discovery_info.host bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) + return await self.async_step_any_discovery(bond_id, host) + + async def async_step_any_discovery( + self, bond_id: str, host: str + ) -> ConfigFlowResult: + """Handle a flow initialized by discovery.""" for entry in self._async_current_entries(): if entry.unique_id != bond_id: continue updates = {CONF_HOST: host} - if entry.state == ConfigEntryState.SETUP_ERROR and ( + if entry.state is ConfigEntryState.SETUP_ERROR and ( token := await async_get_token(self.hass, host) ): updates[CONF_ACCESS_TOKEN] = token @@ -153,10 +171,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self._discovered[CONF_HOST], } try: - _, hub_name = await _validate_input(self.hass, data) + bond_id, hub_name = await _validate_input(self.hass, data) except InputValidationError as error: errors["base"] = error.base else: + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._discovered[CONF_HOST]} + ) return self.async_create_entry( title=hub_name, data=data, @@ -185,8 +207,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): except InputValidationError as error: errors["base"] = error.base else: - await self.async_set_unique_id(bond_id) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(bond_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) return self.async_create_entry(title=hub_name, data=user_input) return self.async_show_form( diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 1d4c110f4fd..704b9934970 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,6 +3,16 @@ "name": "Bond", "codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"], "config_flow": true, + "dhcp": [ + { + "hostname": "bond-*", + "macaddress": "3C6A2C1*" + }, + { + "hostname": "bond-*", + "macaddress": "F44E38*" + } + ], "documentation": "https://www.home-assistant.io/integrations/bond", "iot_class": "local_push", "loggers": ["bond_async"], diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py new file mode 100644 index 00000000000..ddd736b47c0 --- /dev/null +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -0,0 +1,67 @@ +"""The Bosch Alarm integration.""" + +from __future__ import annotations + +from ssl import SSLError + +from bosch_alarm_mode2 import Panel + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN + +PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL] + +type BoschAlarmConfigEntry = ConfigEntry[Panel] + + +async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool: + """Set up Bosch Alarm from a config entry.""" + + panel = Panel( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + automation_code=entry.data.get(CONF_PASSWORD), + installer_or_user_code=entry.data.get( + CONF_INSTALLER_CODE, entry.data.get(CONF_USER_CODE) + ), + ) + try: + await panel.connect() + except (PermissionError, ValueError) as err: + await panel.disconnect() + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err: + await panel.disconnect() + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + entry.runtime_data = panel + + device_registry = dr.async_get(hass) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.unique_id or entry.entry_id)}, + name=f"Bosch {panel.model}", + manufacturer="Bosch Security Systems", + model=panel.model, + sw_version=panel.firmware_version, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.disconnect() + return unload_ok diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py new file mode 100644 index 00000000000..a1d8a7b90f4 --- /dev/null +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -0,0 +1,109 @@ +"""Support for Bosch Alarm Panel.""" + +from __future__ import annotations + +from bosch_alarm_mode2 import Panel + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + AlarmControlPanelState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschAlarmConfigEntry +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up control panels for each area.""" + panel = config_entry.runtime_data + + async_add_entities( + AreaAlarmControlPanel( + panel, + area_id, + config_entry.unique_id or config_entry.entry_id, + ) + for area_id in panel.areas + ) + + +class AreaAlarmControlPanel(AlarmControlPanelEntity): + """An alarm control panel entity for a bosch alarm panel.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + ) + _attr_code_arm_required = False + _attr_name = None + + def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: + """Initialise a Bosch Alarm control panel entity.""" + self.panel = panel + self._area = panel.areas[area_id] + self._area_id = area_id + self._attr_unique_id = f"{unique_id}_area_{area_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + name=self._area.name, + manufacturer="Bosch Security Systems", + via_device=( + DOMAIN, + unique_id, + ), + ) + + @property + def alarm_state(self) -> AlarmControlPanelState | None: + """Return the state of the alarm.""" + if self._area.is_triggered(): + return AlarmControlPanelState.TRIGGERED + if self._area.is_disarmed(): + return AlarmControlPanelState.DISARMED + if self._area.is_arming(): + return AlarmControlPanelState.ARMING + if self._area.is_pending(): + return AlarmControlPanelState.PENDING + if self._area.is_part_armed(): + return AlarmControlPanelState.ARMED_HOME + if self._area.is_all_armed(): + return AlarmControlPanelState.ARMED_AWAY + return None + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Disarm this panel.""" + await self.panel.area_disarm(self._area_id) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self.panel.area_arm_part(self._area_id) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + await self.panel.area_arm_all(self._area_id) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.panel.connection_status() + + async def async_added_to_hass(self) -> None: + """Run when entity attached to hass.""" + await super().async_added_to_hass() + self._area.status_observer.attach(self.schedule_update_ha_state) + self.panel.connection_status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity removed from hass.""" + await super().async_will_remove_from_hass() + self._area.status_observer.detach(self.schedule_update_ha_state) + self.panel.connection_status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py new file mode 100644 index 00000000000..4b1e3e511fc --- /dev/null +++ b/homeassistant/components/bosch_alarm/config_flow.py @@ -0,0 +1,218 @@ +"""Config flow for Bosch Alarm integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +import logging +import ssl +from typing import Any + +from bosch_alarm_mode2 import Panel +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_CODE, + CONF_HOST, + CONF_MODEL, + CONF_PASSWORD, + CONF_PORT, +) +import homeassistant.helpers.config_validation as cv + +from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=7700): cv.positive_int, + } +) + +STEP_AUTH_DATA_SCHEMA_SOLUTION = vol.Schema( + { + vol.Required(CONF_USER_CODE): str, + } +) + +STEP_AUTH_DATA_SCHEMA_AMAX = vol.Schema( + { + vol.Required(CONF_INSTALLER_CODE): str, + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_AUTH_DATA_SCHEMA_BG = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_INIT_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_CODE): str}) + + +async def try_connect( + data: dict[str, Any], load_selector: int = 0 +) -> tuple[str, int | None]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + panel = Panel( + host=data[CONF_HOST], + port=data[CONF_PORT], + automation_code=data.get(CONF_PASSWORD), + installer_or_user_code=data.get(CONF_INSTALLER_CODE, data.get(CONF_USER_CODE)), + ) + + try: + await panel.connect(load_selector) + finally: + await panel.disconnect() + + return (panel.model, panel.serial_number) + + +class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Bosch Alarm.""" + + def __init__(self) -> None: + """Init config flow.""" + + self._data: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + # Use load_selector = 0 to fetch the panel model without authentication. + (model, serial) = await try_connect(user_input, 0) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + asyncio.exceptions.TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self._data = user_input + self._data[CONF_MODEL] = model + return await self.async_step_auth() + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the auth step.""" + errors: dict[str, str] = {} + + # Each model variant requires a different authentication flow + if "Solution" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_SOLUTION + elif "AMAX" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_AMAX + else: + schema = STEP_AUTH_DATA_SCHEMA_BG + + if user_input is not None: + self._data.update(user_input) + try: + (model, serial_number) = await try_connect( + self._data, Panel.LOAD_EXTENDED_INFO + ) + except (PermissionError, ValueError) as e: + errors["base"] = "invalid_auth" + _LOGGER.error("Authentication Error: %s", e) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if serial_number: + await self.async_set_unique_id(str(serial_number)) + self._abort_if_unique_id_configured() + else: + self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]}) + return self.async_create_entry(title=f"Bosch {model}", data=self._data) + + return self.async_show_form( + step_id="auth", + data_schema=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an authentication error.""" + self._data = dict(entry_data) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reauth step.""" + errors: dict[str, str] = {} + + # Each model variant requires a different authentication flow + if "Solution" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_SOLUTION + elif "AMAX" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_AMAX + else: + schema = STEP_AUTH_DATA_SCHEMA_BG + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + self._data.update(user_input) + try: + (_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO) + except (PermissionError, ValueError) as e: + errors["base"] = "invalid_auth" + _LOGGER.error("Authentication Error: %s", e) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + ) diff --git a/homeassistant/components/bosch_alarm/const.py b/homeassistant/components/bosch_alarm/const.py new file mode 100644 index 00000000000..7205831391c --- /dev/null +++ b/homeassistant/components/bosch_alarm/const.py @@ -0,0 +1,6 @@ +"""Constants for the Bosch Alarm integration.""" + +DOMAIN = "bosch_alarm" +HISTORY_ATTR = "history" +CONF_INSTALLER_CODE = "installer_code" +CONF_USER_CODE = "user_code" diff --git a/homeassistant/components/bosch_alarm/diagnostics.py b/homeassistant/components/bosch_alarm/diagnostics.py new file mode 100644 index 00000000000..2e93052ea95 --- /dev/null +++ b/homeassistant/components/bosch_alarm/diagnostics.py @@ -0,0 +1,73 @@ +"""Diagnostics for bosch alarm.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from . import BoschAlarmConfigEntry +from .const import CONF_INSTALLER_CODE, CONF_USER_CODE + +TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: BoschAlarmConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "data": { + "model": entry.runtime_data.model, + "serial_number": entry.runtime_data.serial_number, + "protocol_version": entry.runtime_data.protocol_version, + "firmware_version": entry.runtime_data.firmware_version, + "areas": [ + { + "id": area_id, + "name": area.name, + "all_ready": area.all_ready, + "part_ready": area.part_ready, + "faults": area.faults, + "alarms": area.alarms, + "disarmed": area.is_disarmed(), + "arming": area.is_arming(), + "pending": area.is_pending(), + "part_armed": area.is_part_armed(), + "all_armed": area.is_all_armed(), + "armed": area.is_armed(), + "triggered": area.is_triggered(), + } + for area_id, area in entry.runtime_data.areas.items() + ], + "points": [ + { + "id": point_id, + "name": point.name, + "open": point.is_open(), + "normal": point.is_normal(), + } + for point_id, point in entry.runtime_data.points.items() + ], + "doors": [ + { + "id": door_id, + "name": door.name, + "open": door.is_open(), + "locked": door.is_locked(), + } + for door_id, door in entry.runtime_data.doors.items() + ], + "outputs": [ + { + "id": output_id, + "name": output.name, + "active": output.is_active(), + } + for output_id, output in entry.runtime_data.outputs.items() + ], + "history_events": entry.runtime_data.events, + }, + } diff --git a/homeassistant/components/bosch_alarm/manifest.json b/homeassistant/components/bosch_alarm/manifest.json new file mode 100644 index 00000000000..a54ace71782 --- /dev/null +++ b/homeassistant/components/bosch_alarm/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "bosch_alarm", + "name": "Bosch Alarm", + "codeowners": ["@mag1024", "@sanjay900"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/bosch_alarm", + "integration_type": "device", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["bosch-alarm-mode2==0.4.3"] +} diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml new file mode 100644 index 00000000000..75c331ede40 --- /dev/null +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions defined + appropriate-polling: + status: exempt + comment: | + No polling + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Device type integration + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No repairs + stale-devices: + status: exempt + comment: | + Device type integration + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + Integration does not make any HTTP requests. + strict-typing: done diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json new file mode 100644 index 00000000000..3123c1697f3 --- /dev/null +++ b/homeassistant/components/bosch_alarm/strings.json @@ -0,0 +1,57 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bosch alarm panel", + "port": "The port used to connect to your Bosch alarm panel. This is usually 7700" + } + }, + "auth": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "installer_code": "Installer code", + "user_code": "User code" + }, + "data_description": { + "password": "The Mode 2 automation code from your panel", + "installer_code": "The installer code from your panel", + "user_code": "The user code from your panel" + } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]" + }, + "data_description": { + "password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to panel." + }, + "authentication_failed": { + "message": "Incorrect credentials for panel." + } + } +} diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index a4d39ea07cc..586543de129 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -170,6 +170,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key="pressure", diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index c0127c20d05..6612ea5209d 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -74,7 +74,7 @@ }, "get_events": { "name": "Get events", - "description": "Get events on a calendar within a time range.", + "description": "Retrieves events on a calendar within a time range.", "fields": { "start_date_time": { "name": "Start time", diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index d18898fa916..5322ae7d9a2 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -142,6 +142,12 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): @property def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" + if ( + not self.client.play_state.metadata.artist + and self.client.state.source == "IR" + ): + # Return channel instead of artist when playing internet radio + return self.client.play_state.metadata.station return self.client.play_state.metadata.artist @property @@ -169,6 +175,11 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): """Last time the media position was updated.""" return self.client.position_last_updated + @property + def media_channel(self) -> str | None: + """Channel currently playing.""" + return self.client.play_state.metadata.station + @property def is_volume_muted(self) -> bool | None: """Volume mute status.""" diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index bbe85bf82db..971e6804add 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -2,17 +2,10 @@ from __future__ import annotations -from contextlib import suppress import logging from typing import TYPE_CHECKING, Literal, cast -with suppress(Exception): - # TurboJPEG imports numpy which may or may not work so - # we have to guard the import here. We still want - # to import it at top level so it gets loaded - # in the import executor and not in the event loop. - from turbojpeg import TurboJPEG - +from turbojpeg import TurboJPEG if TYPE_CHECKING: from . import Image diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 7f46100afca..c45bbb4fbbc 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -81,7 +81,7 @@ class ChromecastInfo: "+label%3A%22integration%3A+cast%22" ) - _LOGGER.debug( + _LOGGER.info( ( "Fetched cast details for unknown model '%s' manufacturer:" " '%s', type: '%s'. Please %s" diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index feb613f4765..6c8b0536e2f 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,7 +14,7 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==14.0.6"], + "requirements": ["PyChromecast==14.0.7"], "single_config_entry": true, "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/chacon_dio/config_flow.py b/homeassistant/components/chacon_dio/config_flow.py index 54604b81153..daaf38e0edc 100644 --- a/homeassistant/components/chacon_dio/config_flow.py +++ b/homeassistant/components/chacon_dio/config_flow.py @@ -44,7 +44,7 @@ class ChaconDioConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except DIOChaconInvalidAuthError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 6d8b2c5449d..4682419d1e9 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -98,13 +98,13 @@ "name": "Preset", "state": { "none": "None", - "eco": "Eco", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", + "activity": "Activity", "boost": "Boost", "comfort": "Comfort", - "home": "[%key:common::state::home%]", - "sleep": "Sleep", - "activity": "Activity" + "eco": "Eco", + "sleep": "Sleep" } }, "preset_modes": { @@ -257,7 +257,7 @@ "selector": { "hvac_mode": { "options": { - "off": "Off", + "off": "[%key:common::state::off%]", "auto": "Auto", "cool": "Cool", "dry": "Dry", diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 851d658f8e0..3c3d944d479 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement flow_id=flow_id, user_input=tokens ) - self.hass.async_create_task(await_tokens()) + # It's a background task because it should be cancelled on shutdown and there's nothing else + # we can do in such case. There's also no need to wait for this during setup. + self.hass.async_create_background_task( + await_tokens(), name="Awaiting OAuth tokens" + ) return authorize_url diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index b83c4725663..f4426eabeed 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -4,13 +4,14 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping +from http import HTTPStatus import logging import random from typing import Any -from aiohttp import ClientError +from aiohttp import ClientError, ClientResponseError from hass_nabucasa import Cloud, CloudError -from hass_nabucasa.api import CloudApiNonRetryableError +from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError from hass_nabucasa.cloud_api import ( FilesHandlerListEntry, async_files_delete_file, @@ -120,6 +121,8 @@ class CloudBackupAgent(BackupAgent): """ if not backup.protected: raise BackupAgentError("Cloud backups must be protected") + if self._cloud.subscription_expired: + raise BackupAgentError("Cloud subscription has expired") size = backup.size try: @@ -152,6 +155,13 @@ class CloudBackupAgent(BackupAgent): ) from err raise BackupAgentError(f"Failed to upload backup {err}") from err except CloudError as err: + if ( + isinstance(err, CloudApiError) + and isinstance(err.orig_exc, ClientResponseError) + and err.orig_exc.status == HTTPStatus.FORBIDDEN + and self._cloud.subscription_expired + ): + raise BackupAgentError("Cloud subscription has expired") from err if tries == _RETRY_LIMIT: raise BackupAgentError(f"Failed to upload backup {err}") from err tries += 1 diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 73952d80f6c..6f18cc424cd 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -245,6 +245,10 @@ class CloudLoginView(HomeAssistantView): name = "api:cloud:login" @require_admin + async def post(self, request: web.Request) -> web.Response: + """Handle login request.""" + return await self._post(request) + @_handle_cloud_errors @RequestDataValidator( vol.Schema( @@ -259,7 +263,7 @@ class CloudLoginView(HomeAssistantView): ) ) ) - async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle login request.""" hass = request.app[KEY_HASS] cloud = hass.data[DATA_CLOUD] @@ -316,8 +320,12 @@ class CloudLogoutView(HomeAssistantView): name = "api:cloud:logout" @require_admin - @_handle_cloud_errors async def post(self, request: web.Request) -> web.Response: + """Handle logout request.""" + return await self._post(request) + + @_handle_cloud_errors + async def _post(self, request: web.Request) -> web.Response: """Handle logout request.""" hass = request.app[KEY_HASS] cloud = hass.data[DATA_CLOUD] @@ -400,9 +408,13 @@ class CloudForgotPasswordView(HomeAssistantView): name = "api:cloud:forgot_password" @require_admin + async def post(self, request: web.Request) -> web.Response: + """Handle forgot password request.""" + return await self._post(request) + @_handle_cloud_errors @RequestDataValidator(vol.Schema({vol.Required("email"): str})) - async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle forgot password request.""" hass = request.app[KEY_HASS] cloud = hass.data[DATA_CLOUD] diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index c3845a447e4..1fad38c5afc 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -9,7 +9,6 @@ from typing import Any import pycfdns import voluptuous as vol -from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_ZONE from homeassistant.core import HomeAssistant @@ -118,8 +117,6 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - persistent_notification.async_dismiss(self.hass, "cloudflare_setup") - errors: dict[str, str] = {} if user_input is not None: diff --git a/homeassistant/components/cloudflare/strings.json b/homeassistant/components/cloudflare/strings.json index 8c8ec57b074..453135f47a0 100644 --- a/homeassistant/components/cloudflare/strings.json +++ b/homeassistant/components/cloudflare/strings.json @@ -4,19 +4,19 @@ "step": { "user": { "title": "Connect to Cloudflare", - "description": "This integration requires an API Token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.", + "description": "This integration requires an API token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.", "data": { "api_token": "[%key:common::config_flow::data::api_token%]" } }, "zone": { - "title": "Choose the Zone to Update", + "title": "Choose the zone to update", "data": { "zone": "Zone" } }, "records": { - "title": "Choose the Records to Update", + "title": "Choose the records to update", "data": { "records": "Records" } @@ -40,7 +40,7 @@ "services": { "update_records": { "name": "Update records", - "description": "Manually trigger update to Cloudflare records." + "description": "Manually triggers an update of Cloudflare records." } } } diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index 5ecc9a63599..1ad26905dd1 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -41,6 +41,7 @@ ALARM_ACTIONS: dict[str, str] = { ALARM_AREA_ARMED_STATUS: dict[str, int] = { + DISABLE: 0, HOME_P1: 1, HOME_P2: 2, NIGHT: 3, @@ -128,20 +129,38 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel AlarmAreaState.TRIGGERED: AlarmControlPanelState.TRIGGERED, }.get(self._area.human_status) + async def _async_update_state(self, area_state: AlarmAreaState, armed: int) -> None: + """Update state after action.""" + self._area.human_status = area_state + self._area.armed = armed + await self.async_update_ha_state() + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if code != str(self._api.device_pin): return await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE]) + await self._async_update_state( + AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE] + ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY]) + await self._async_update_state( + AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY] + ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME]) + await self._async_update_state( + AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1] + ) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT]) + await self._async_update_state( + AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT] + ) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 8064d478c32..3ec79001d55 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -119,10 +119,10 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device, device.type) + self._update_attributes() - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _update_attributes(self) -> None: + """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] if not isinstance(device.val, list): raise HomeAssistantError( @@ -158,6 +158,12 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity self._attr_target_temperature = values[4] / 10 + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attributes() + super()._handle_coordinator_update() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ( @@ -171,6 +177,8 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, ClimaComelitCommand.SET, target_temp ) + self._attr_target_temperature = target_temp + self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" @@ -182,3 +190,5 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, MODE_TO_ACTION[hvac_mode] ) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index 84d8fbd6315..f52f33fd6da 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -9,3 +9,5 @@ _LOGGER = logging.getLogger(__package__) DOMAIN = "comelit" DEFAULT_PORT = 80 DEVICE_TYPE_LIST = [BRIDGE, VEDO] + +SCAN_INTERVAL = 5 diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index b3be3a47825..df4965d9945 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -22,7 +22,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import _LOGGER, DOMAIN +from .const import _LOGGER, DOMAIN, SCAN_INTERVAL type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator] @@ -53,7 +53,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]): logger=_LOGGER, config_entry=entry, name=f"{DOMAIN}-{host}-coordinator", - update_interval=timedelta(seconds=5), + update_interval=timedelta(seconds=SCAN_INTERVAL), ) device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 9bcf52ac111..befcb0c35d4 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -8,7 +8,7 @@ from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -98,13 +98,20 @@ class ComelitCoverEntity( """Return if the cover is opening.""" return self._current_action("opening") + async def _cover_set_state(self, action: int, state: int) -> None: + """Set desired cover state.""" + self._last_state = self.state + await self._api.set_device_status(COVER, self._device.index, action) + self.coordinator.data[COVER][self._device.index].status = state + self.async_write_ha_state() + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self._api.set_device_status(COVER, self._device.index, STATE_OFF) + await self._cover_set_state(STATE_OFF, 2) async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" - await self._api.set_device_status(COVER, self._device.index, STATE_ON) + await self._cover_set_state(STATE_ON, 1) async def async_stop_cover(self, **_kwargs: Any) -> None: """Stop the cover.""" @@ -112,13 +119,7 @@ class ComelitCoverEntity( return action = STATE_ON if self.is_closing else STATE_OFF - await self._api.set_device_status(COVER, self._device.index, action) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle device update.""" - self._last_state = self.state - self.async_write_ha_state() + await self._cover_set_state(action, 0) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index c5edfb1c2de..d7b20f731a9 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -124,10 +124,10 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier self._active_mode = active_mode self._active_action = active_action self._set_command = set_command + self._update_attributes() - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _update_attributes(self) -> None: + """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] if not isinstance(device.val, list): raise HomeAssistantError( @@ -154,9 +154,15 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier self._attr_mode = MODE_AUTO if _automatic else MODE_NORMAL self._attr_target_humidity = values[4] / 10 + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attributes() + super()._handle_coordinator_update() + async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - if self.mode == HumidifierComelitMode.OFF: + if not self._attr_is_on: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="humidity_while_off", @@ -168,21 +174,29 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier await self.coordinator.api.set_humidity_status( self._device.index, HumidifierComelitCommand.SET, humidity ) + self._attr_target_humidity = humidity + self.async_write_ha_state() async def async_set_mode(self, mode: str) -> None: """Set humidifier mode.""" await self.coordinator.api.set_humidity_status( self._device.index, MODE_TO_ACTION[mode] ) + self._attr_mode = mode + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" await self.coordinator.api.set_humidity_status( self._device.index, self._set_command ) + self._attr_is_on = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" await self.coordinator.api.set_humidity_status( self._device.index, HumidifierComelitCommand.OFF ) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 09180d628a6..53cf6bdcb46 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -59,7 +59,8 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): async def _light_set_state(self, state: int) -> None: """Set desired light state.""" await self.coordinator.api.set_device_status(LIGHT, self._device.index, state) - await self.coordinator.async_request_refresh() + self.coordinator.data[LIGHT][self._device.index].status = state + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 8836af4e8dd..3abfc222e7d 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.11.2"] + "requirements": ["aiocomelit==0.11.3"] } diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 5ff4fa54688..d4d0b8f670f 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -3,19 +3,25 @@ "flow_title": "{host}", "step": { "reauth_confirm": { - "description": "Please enter the correct PIN for {host}", "data": { "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The PIN of your Comelit device." } }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", - "pin": "[%key:common::config_flow::data::pin%]" + "pin": "[%key:common::config_flow::data::pin%]", + "type": "Device type" }, "data_description": { - "host": "The hostname or IP address of your Comelit device." + "host": "The hostname or IP address of your Comelit device.", + "port": "The port of your Comelit device.", + "pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]", + "type": "The type of your Comelit device." } } }, @@ -36,9 +42,9 @@ "sensor": { "zone_status": { "state": { + "open": "[%key:common::state::open%]", "alarm": "Alarm", "armed": "Armed", - "open": "Open", "excluded": "Excluded", "faulty": "Faulty", "inhibited": "Inhibited", @@ -46,7 +52,9 @@ "rest": "Rest", "sabotated": "Sabotated" } - }, + } + }, + "humidifier": { "humidifier": { "name": "Humidifier" }, diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index db89bd082f6..2c751cbe2cb 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -67,7 +67,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): await self.coordinator.api.set_device_status( self._device.type, self._device.index, state ) - await self.coordinator.async_request_refresh() + self.coordinator.data[self._device.type][self._device.index].status = state + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" diff --git a/homeassistant/components/constructa/__init__.py b/homeassistant/components/constructa/__init__.py new file mode 100644 index 00000000000..1b3870860a0 --- /dev/null +++ b/homeassistant/components/constructa/__init__.py @@ -0,0 +1 @@ +"""Constructa virtual integration.""" diff --git a/homeassistant/components/constructa/manifest.json b/homeassistant/components/constructa/manifest.json new file mode 100644 index 00000000000..7b73f2e2ed0 --- /dev/null +++ b/homeassistant/components/constructa/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "constructa", + "name": "Constructa", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 14c5244c18b..25aaf6df290 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable import logging -import re from typing import Literal from hassil.recognize import RecognizeResult @@ -91,8 +90,6 @@ __all__ = [ _LOGGER = logging.getLogger(__name__) -REGEX_TYPE = type(re.compile("")) - SERVICE_PROCESS_SCHEMA = vol.Schema( { vol.Required(ATTR_TEXT): cv.string, diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 2de785dae7d..9ffcc7fc0d5 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -8,7 +8,7 @@ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict, dataclass, field, replace import logging -from typing import Literal, TypedDict +from typing import Any, Literal, TypedDict import voluptuous as vol @@ -354,6 +354,35 @@ class ChatLog: if self.delta_listener: self.delta_listener(self, asdict(tool_result)) + async def _async_expand_prompt_template( + self, + llm_context: llm.LLMContext, + prompt: str, + language: str, + user_name: str | None = None, + ) -> str: + try: + return template.Template(prompt, self.hass).async_render( + { + "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, + }, + parse_result=False, + ) + except TemplateError as err: + LOGGER.error("Error rendering prompt: %s", err) + intent_response = intent.IntentResponse(language=language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Sorry, I had a problem with my template", + ) + raise ConverseError( + "Error rendering prompt", + conversation_id=self.conversation_id, + response=intent_response, + ) from err + async def async_update_llm_data( self, conversing_domain: str, @@ -409,38 +438,28 @@ class ChatLog: ): user_name = user.name - try: - prompt_parts = [ - template.Template( - llm.BASE_PROMPT - + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) - ] - - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem with my template", + prompt_parts = [] + prompt_parts.append( + await self._async_expand_prompt_template( + llm_context, + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), + user_input.language, + user_name, ) - raise ConverseError( - "Error rendering prompt", - conversation_id=self.conversation_id, - response=intent_response, - ) from err + ) if llm_api: prompt_parts.append(llm_api.api_prompt) + prompt_parts.append( + await self._async_expand_prompt_template( + llm_context, + llm.BASE_PROMPT, + user_input.language, + user_name, + ) + ) + if extra_system_prompt := ( # Take new system prompt if one was given user_input.extra_system_prompt or self.extra_system_prompt @@ -456,10 +475,16 @@ class ChatLog: LOGGER.debug("Prompt: %s", self.content) LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None) - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, + self.async_trace( { "messages": self.content, "tools": self.llm_api.tools if self.llm_api else None, - }, + } + ) + + def async_trace(self, agent_details: dict[str, Any]) -> None: + """Append agent specific details to the conversation trace.""" + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, + agent_details, ) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index c30e8bb4a92..bed4b4c0dd6 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -650,7 +650,14 @@ class DefaultAgent(ConversationEntity): if ( (maybe_result is None) # first result - or (num_matched_entities > best_num_matched_entities) + or ( + # More literal text matched + result.text_chunks_matched > maybe_result.text_chunks_matched + ) + or ( + # More entities matched + num_matched_entities > best_num_matched_entities + ) or ( # Fewer unmatched entities (num_matched_entities == best_num_matched_entities) @@ -662,16 +669,6 @@ class DefaultAgent(ConversationEntity): and (num_unmatched_entities == best_num_unmatched_entities) and (num_unmatched_ranges > best_num_unmatched_ranges) ) - or ( - # More literal text matched - (num_matched_entities == best_num_matched_entities) - and (num_unmatched_entities == best_num_unmatched_entities) - and (num_unmatched_ranges == best_num_unmatched_ranges) - and ( - result.text_chunks_matched - > maybe_result.text_chunks_matched - ) - ) or ( # Prefer match failures with entities (result.text_chunks_matched == maybe_result.text_chunks_matched) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 4d8526a4fd4..efcdcb8d69b 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -3,11 +3,13 @@ from __future__ import annotations from collections.abc import Iterable +from dataclasses import asdict from typing import Any from aiohttp import web from hassil.recognize import MISSING_ENTITY, RecognizeResult from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity +from home_assistant_intents import get_language_scores import voluptuous as vol from homeassistant.components import http, websocket_api @@ -38,6 +40,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_list_agents) websocket_api.async_register_command(hass, websocket_list_sentences) websocket_api.async_register_command(hass, websocket_hass_agent_debug) + websocket_api.async_register_command(hass, websocket_hass_agent_language_scores) @websocket_api.websocket_command( @@ -336,6 +339,36 @@ def _get_unmatched_slots( return unmatched_slots +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/agent/homeassistant/language_scores", + vol.Optional("language"): str, + vol.Optional("country"): str, + } +) +@websocket_api.async_response +async def websocket_hass_agent_language_scores( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get support scores per language.""" + language = msg.get("language", hass.config.language) + country = msg.get("country", hass.config.country) + + scores = await hass.async_add_executor_job(get_language_scores) + matching_langs = language_util.matches(language, scores.keys(), country=country) + preferred_lang = matching_langs[0] if matching_langs else language + result = { + "languages": { + lang_key: asdict(lang_scores) for lang_key, lang_scores in scores.items() + }, + "preferred_language": preferred_lang, + } + + connection.send_result(msg["id"], result) + + class ConversationProcessView(http.HomeAssistantView): """View to process text.""" diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index ea950ace323..a1281764bd5 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.5"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.28"] } diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py deleted file mode 100644 index 4326c95cb66..00000000000 --- a/homeassistant/components/conversation/util.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Util for Conversation.""" - -from __future__ import annotations - -import re - - -def create_matcher(utterance: str) -> re.Pattern[str]: - """Create a regex that matches the utterance.""" - # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL - # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} - parts = re.split(r"({\w+}|\[[\w\s]+\] *)", utterance) - # Pattern to extract name from GROUP part. Matches {name} - group_matcher = re.compile(r"{(\w+)}") - # Pattern to extract text from OPTIONAL part. Matches [the color] - optional_matcher = re.compile(r"\[([\w ]+)\] *") - - pattern = ["^"] - for part in parts: - group_match = group_matcher.match(part) - optional_match = optional_matcher.match(part) - - # Normal part - if group_match is None and optional_match is None: - pattern.append(part) - continue - - # Group part - if group_match is not None: - pattern.append(rf"(?P<{group_match.groups()[0]}>[\w ]+?)\s*") - - # Optional part - elif optional_match is not None: - pattern.append(rf"(?:{optional_match.groups()[0]} *)?") - - pattern.append("$") - return re.compile("".join(pattern), re.IGNORECASE) diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index ae384fb6635..52f99133546 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -6,7 +6,7 @@ "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" }, "data_description": { "email": "Email used to access your {cookidoo} account.", diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 0afef8a200f..6ca8b50620f 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -38,10 +38,10 @@ "name": "[%key:component::cover::title%]", "state": { "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "closed": "[%key:common::state::closed%]", - "closing": "Closing", - "stopped": "Stopped" + "closing": "[%key:common::state::closing%]", + "stopped": "[%key:common::state::stopped%]" }, "state_attributes": { "current_position": { diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 0eaffa39ee9..88a7b71e3ed 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.util.ssl import client_context_no_verify from .const import KEY_MAC, TIMEOUT from .coordinator import DaikinConfigEntry, DaikinCoordinator @@ -48,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo key=entry.data.get(CONF_API_KEY), uuid=entry.data.get(CONF_UUID), password=entry.data.get(CONF_PASSWORD), + ssl_context=client_context_no_verify(), ) _LOGGER.debug("Connection to %s successful", host) except TimeoutError as err: diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 86fc804ec92..947fe514747 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.14.1"], + "requirements": ["pydaikin==2.15.0"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 93ae8e392c8..5664e6abc8a 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pydeconz"], - "requirements": ["pydeconz==118"], + "requirements": ["pydeconz==120"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index 19afe26e8f9..78eced64c7c 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from ssl import SSLError from typing import Any @@ -21,6 +22,8 @@ from .const import ( DOMAIN, ) +_LOGGER = logging.getLogger(__name__) + class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Deluge.""" @@ -86,7 +89,8 @@ class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job(api.connect) except (ConnectionRefusedError, TimeoutError, SSLError): return "cannot_connect" - except Exception as ex: # noqa: BLE001 + except Exception as ex: + _LOGGER.exception("Unexpected error") if type(ex).__name__ == "BadLoginError": return "invalid_auth" return "unknown" diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index 6adde8ef7df..ddea78b315f 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls", + "description": "To be able to use this integration, you have to enable the following option in Deluge settings: Daemon > Allow remote controls", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py index 90917e0ce2c..ed6dc94e764 100644 --- a/homeassistant/components/dexcom/config_flow.py +++ b/homeassistant/components/dexcom/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from pydexcom import AccountError, Dexcom, SessionError @@ -12,6 +13,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import CONF_SERVER, DOMAIN, SERVER_OUS, SERVER_US +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -43,7 +46,8 @@ class DexcomConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except AccountError: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected error") errors["base"] = "unknown" if "base" not in errors: diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 9cf63176de6..0a8b7422f84 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -9,7 +9,7 @@ import pydiscovergy.error as discovergyError from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client from .coordinator import DiscovergyConfigEntry, DiscovergyUpdateCoordinator @@ -21,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) - client = Discovergy( email=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], - httpx_client=get_async_client(hass), + httpx_client=create_async_httpx_client(hass), authentication=BasicAuth(), ) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 82541476a02..119d1d31d52 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.43.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], "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 17fc3dc27e8..0289d5100d6 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.43.0"], + "requirements": ["async-upnp-client==0.44.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json index 93df4dc3310..6093f2e8100 100644 --- a/homeassistant/components/drop_connect/strings.json +++ b/homeassistant/components/drop_connect/strings.json @@ -38,8 +38,8 @@ "protect_mode": { "name": "Protect mode", "state": { - "away": "Away", - "home": "Home", + "away": "[%key:common::state::not_home%]", + "home": "[%key:common::state::home%]", "schedule": "Schedule" } } diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 561f06d1bbe..f9e78ac616f 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.4.2"] + "requirements": ["dsmr-parser==1.4.3"] } diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 871dd382f2b..e95e9ae870a 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -51,8 +51,8 @@ "electricity_active_tariff": { "name": "Active tariff", "state": { - "low": "Low", - "normal": "Normal" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]" } }, "electricity_delivered_tariff_1": { diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index 90cf0533a72..d405898a393 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -140,8 +140,8 @@ "electricity_tariff": { "name": "Electricity tariff", "state": { - "low": "Low", - "high": "High" + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" } }, "power_failure_count": { diff --git a/homeassistant/components/duke_energy/config_flow.py b/homeassistant/components/duke_energy/config_flow.py index e06940b0fba..2ec92ff4c12 100644 --- a/homeassistant/components/duke_energy/config_flow.py +++ b/homeassistant/components/duke_energy/config_flow.py @@ -50,10 +50,10 @@ class DukeEnergyConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - username = auth["cdp_internal_user_id"].lower() + username = auth["internalUserID"].lower() await self.async_set_unique_id(username) self._abort_if_unique_id_configured() - email = auth["email"].lower() + email = auth["loginEmailAddress"].lower() data = { CONF_EMAIL: email, CONF_USERNAME: username, diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py index 12a2f5fd6ae..a76168475c0 100644 --- a/homeassistant/components/duke_energy/coordinator.py +++ b/homeassistant/components/duke_energy/coordinator.py @@ -8,7 +8,11 @@ from aiodukeenergy import DukeEnergy from aiohttp import ClientError from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -137,7 +141,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}" ) consumption_metadata = StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{name_prefix} Consumption", source=DOMAIN, diff --git a/homeassistant/components/duke_energy/manifest.json b/homeassistant/components/duke_energy/manifest.json index ece18d7ad2a..ad64fdd5cc4 100644 --- a/homeassistant/components/duke_energy/manifest.json +++ b/homeassistant/components/duke_energy/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/duke_energy", "iot_class": "cloud_polling", - "requirements": ["aiodukeenergy==0.2.2"] + "requirements": ["aiodukeenergy==0.3.0"] } diff --git a/homeassistant/components/dynalite/strings.json b/homeassistant/components/dynalite/strings.json index 468cdebf0b1..4f73f91113b 100644 --- a/homeassistant/components/dynalite/strings.json +++ b/homeassistant/components/dynalite/strings.json @@ -36,7 +36,7 @@ }, "request_channel_level": { "name": "Request channel level", - "description": "Requests Dynalite to report the level of a specific channel.", + "description": "Requests Dynalite to report the brightness level of a specific channel.", "fields": { "host": { "name": "[%key:common::config_flow::data::host%]", @@ -48,7 +48,7 @@ }, "channel": { "name": "Channel", - "description": "Channel to request the level for." + "description": "Channel to request the brightness level for." } } } diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 2b44c45edef..bc61cb444c1 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -25,7 +25,7 @@ "state_attributes": { "preset_mode": { "state": { - "away_indefinitely": "Away Indefinitely" + "away_indefinitely": "Away indefinitely" } } } @@ -55,7 +55,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to create the vacation." + "description": "ecobee thermostat on which to create the vacation." }, "vacation_name": { "name": "Vacation name", @@ -91,7 +91,7 @@ }, "fan_min_on_time": { "name": "Fan minimum on time", - "description": "Minimum number of minutes to run the fan each hour (0 to 60) during the vacation." + "description": "Minimum number of minutes to run the fan each hour during the vacation." } } }, @@ -101,7 +101,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to delete the vacation." + "description": "ecobee thermostat on which to delete the vacation." }, "vacation_name": { "name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]", @@ -125,7 +125,7 @@ }, "set_fan_min_on_time": { "name": "Set fan minimum on time", - "description": "Sets the minimum fan on time.", + "description": "Sets the minimum amount of time that the fan will run per hour.", "fields": { "entity_id": { "name": "Entity", @@ -133,7 +133,7 @@ }, "fan_min_on_time": { "name": "[%key:component::ecobee::services::create_vacation::fields::fan_min_on_time::name%]", - "description": "New value of fan min on time." + "description": "Minimum number of minutes to run the fan each hour." } } }, @@ -149,7 +149,7 @@ }, "set_mic_mode": { "name": "Set mic mode", - "description": "Enables/disables Alexa microphone (only for Ecobee 4).", + "description": "Enables/disables Alexa microphone (only for ecobee 4).", "fields": { "mic_enabled": { "name": "Mic enabled", @@ -177,7 +177,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to set active sensors." + "description": "ecobee thermostat on which to set active sensors." }, "preset_mode": { "name": "Climate Name", @@ -203,12 +203,12 @@ }, "issues": { "migrate_aux_heat": { - "title": "Migration of Ecobee set_aux_heat action", + "title": "Migration of ecobee set_aux_heat action", "fix_flow": { "step": { "confirm": { - "description": "The Ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", - "title": "Disable legacy Ecobee set_aux_heat action" + "description": "The ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", + "title": "Disable legacy ecobee set_aux_heat action" } } } diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index c1d4aca6f0c..d0e4c17abe1 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -132,7 +132,7 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( ), EcoforestSensorEntityDescription( key="convecto_air_flow", - translation_key="convecto_air_flow", + translation_key="convector_air_flow", native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=False, value_fn=lambda data: data.convecto_air_flow, diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index 1094e10ada3..d0e807b5f2a 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -78,8 +78,8 @@ "extractor": { "name": "Extractor" }, - "convecto_air_flow": { - "name": "Convecto air flow" + "convector_air_flow": { + "name": "Convector air flow" } }, "number": { diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index fb74ae8b4a5..f93ad7f8872 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -91,15 +91,15 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity): def operation_list(self) -> list[str]: """List of available operation modes.""" econet_modes = self.water_heater.modes - op_list = [] + operation_modes = set() for mode in econet_modes: if ( mode is not WaterHeaterOperationMode.UNKNOWN and mode is not WaterHeaterOperationMode.VACATION ): ha_mode = ECONET_STATE_TO_HA[mode] - op_list.append(ha_mode) - return op_list + operation_modes.add(ha_mode) + return list(operation_modes) @property def supported_features(self) -> WaterHeaterEntityFeature: diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 6d3dc5c9be6..ad8b3ea70a5 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.3.1"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.5.0"] } diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 44c51c7ae43..f74c8b90f00 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -14,7 +14,7 @@ "step": { "auth": { "data": { - "country": "Country", + "country": "[%key:common::config_flow::data::country%]", "override_rest_url": "REST URL", "override_mqtt_url": "MQTT URL", "password": "[%key:common::config_flow::data::password%]", @@ -176,9 +176,9 @@ "water_amount": { "name": "Water flow level", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "ultrahigh": "Ultrahigh" } }, diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 6968acdfa4f..7d37aa40b86 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -68,6 +68,7 @@ ECOWITT_SENSORS_MAPPING: Final = { key="DEGREE", native_unit_of_measurement=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), EcoWittSensorTypes.WATT_METERS_SQUARED: SensorEntityDescription( key="WATT_METERS_SQUARED", diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index 26e6bea4d4a..e4fb7989931 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] +PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR] async def async_setup_entry( diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py index c6535608b0c..b0432267c8e 100644 --- a/homeassistant/components/eheimdigital/config_flow.py +++ b/homeassistant/components/eheimdigital/config_flow.py @@ -62,6 +62,7 @@ class EheimDigitalConfigFlow(ConfigFlow, domain=DOMAIN): except (ClientError, TimeoutError): return self.async_abort(reason="cannot_connect") except Exception: # noqa: BLE001 + LOGGER.exception("Unknown exception occurred") return self.async_abort(reason="unknown") await self.async_set_unique_id(hub.main.mac_address) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) diff --git a/homeassistant/components/eheimdigital/icons.json b/homeassistant/components/eheimdigital/icons.json new file mode 100644 index 00000000000..32f3f1eee9c --- /dev/null +++ b/homeassistant/components/eheimdigital/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "current_speed": { + "default": "mdi:pump" + }, + "service_hours": { + "default": "mdi:wrench-clock" + }, + "error_code": { + "default": "mdi:alert-octagon", + "state": { + "no_error": "mdi:check-circle" + } + } + } + } +} diff --git a/homeassistant/components/eheimdigital/sensor.py b/homeassistant/components/eheimdigital/sensor.py new file mode 100644 index 00000000000..3d809cc14dc --- /dev/null +++ b/homeassistant/components/eheimdigital/sensor.py @@ -0,0 +1,114 @@ +"""EHEIM Digital sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, TypeVar, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.types import FilterErrorCode + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor.const import SensorDeviceClass +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital sensor entities.""" + + value_fn: Callable[[_DeviceT_co], float | str | None] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalSensorDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="current_speed", + translation_key="current_speed", + value_fn=lambda device: device.current_speed, + native_unit_of_measurement=PERCENTAGE, + ), + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="service_hours", + translation_key="service_hours", + value_fn=lambda device: device.service_hours, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + suggested_unit_of_measurement=UnitOfTime.DAYS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="error_code", + translation_key="error_code", + value_fn=( + lambda device: device.error_code.name.lower() + if device.error_code is not None + else None + ), + device_class=SensorDeviceClass.ENUM, + options=[name.lower() for name in FilterErrorCode._member_names_], + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so lights can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the light entities for one or multiple devices.""" + entities: list[EheimDigitalSensor[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities += [ + EheimDigitalSensor[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ] + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalSensor( + EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co] +): + """Represent a EHEIM Digital sensor entity.""" + + entity_description: EheimDigitalSensorDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalSensorDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital number entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @override + def _async_update_attrs(self) -> None: + self._attr_native_value = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index ef6f6b10d0a..81fa521bbaf 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -46,6 +46,22 @@ } } } + }, + "sensor": { + "current_speed": { + "name": "Current speed" + }, + "service_hours": { + "name": "Remaining hours until service" + }, + "error_code": { + "name": "Error code", + "state": { + "no_error": "No error", + "rotor_stuck": "Rotor stuck", + "air_in_filter": "Air in filter" + } + } } } } diff --git a/homeassistant/components/elkm1/entity.py b/homeassistant/components/elkm1/entity.py index d9967d93967..ce717578eae 100644 --- a/homeassistant/components/elkm1/entity.py +++ b/homeassistant/components/elkm1/entity.py @@ -100,7 +100,11 @@ class ElkEntity(Entity): return {"index": self._element.index + 1} def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: - pass + """Handle changes to the element. + + This method is called when the element changes. It should be + overridden by subclasses to handle the changes. + """ @callback def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None: @@ -111,7 +115,7 @@ class ElkEntity(Entity): async def async_added_to_hass(self) -> None: """Register callback for ElkM1 changes and update entity state.""" self._element.add_callback(self._element_callback) - self._element_callback(self._element, {}) + self._element_changed(self._element, {}) @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/elvia/importer.py b/homeassistant/components/elvia/importer.py index 4e8b7f716ef..caca787237c 100644 --- a/homeassistant/components/elvia/importer.py +++ b/homeassistant/components/elvia/importer.py @@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, cast from elvia import Elvia, error as ElviaError -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -144,7 +148,7 @@ class ElviaImporter: async_add_external_statistics( hass=self.hass, metadata=StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{self.metering_point_id} Consumption", source=DOMAIN, diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py index d4466f47ef2..e8c3a00f098 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -46,6 +46,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type EmulatedRokuConfigEntry = ConfigEntry[EmulatedRoku] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the emulated roku component.""" @@ -65,22 +67,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: EmulatedRokuConfigEntry +) -> bool: """Set up an emulated roku server from a config entry.""" - config = config_entry.data - - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - - name = config[CONF_NAME] - listen_port = config[CONF_LISTEN_PORT] - host_ip = config.get(CONF_HOST_IP) or await async_get_source_ip(hass) - advertise_ip = config.get(CONF_ADVERTISE_IP) - advertise_port = config.get(CONF_ADVERTISE_PORT) - upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST) + config = entry.data + name: str = config[CONF_NAME] + listen_port: int = config[CONF_LISTEN_PORT] + host_ip: str = config.get(CONF_HOST_IP) or await async_get_source_ip(hass) + advertise_ip: str | None = config.get(CONF_ADVERTISE_IP) + advertise_port: int | None = config.get(CONF_ADVERTISE_PORT) + upnp_bind_multicast: bool | None = config.get(CONF_UPNP_BIND_MULTICAST) server = EmulatedRoku( hass, + entry.entry_id, name, host_ip, listen_port, @@ -88,14 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b advertise_port, upnp_bind_multicast, ) - - hass.data[DOMAIN][name] = server - + entry.runtime_data = server return await server.setup() -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: EmulatedRokuConfigEntry +) -> bool: """Unload a config entry.""" - name = entry.data[CONF_NAME] - server = hass.data[DOMAIN].pop(name) - return await server.unload() + return await entry.runtime_data.unload() diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py index a84db4bd77b..6d8d9c4014f 100644 --- a/homeassistant/components/emulated_roku/binding.py +++ b/homeassistant/components/emulated_roku/binding.py @@ -5,7 +5,13 @@ import logging from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CoreState, EventOrigin +from homeassistant.core import ( + CALLBACK_TYPE, + CoreState, + Event, + EventOrigin, + HomeAssistant, +) LOGGER = logging.getLogger(__package__) @@ -27,16 +33,18 @@ class EmulatedRoku: def __init__( self, - hass, - name, - host_ip, - listen_port, - advertise_ip, - advertise_port, - upnp_bind_multicast, - ): + hass: HomeAssistant, + entry_id: str, + name: str, + host_ip: str, + listen_port: int, + advertise_ip: str | None, + advertise_port: int | None, + upnp_bind_multicast: bool | None, + ) -> None: """Initialize the properties.""" self.hass = hass + self.entry_id = entry_id self.roku_usn = name self.host_ip = host_ip @@ -47,21 +55,21 @@ class EmulatedRoku: self.bind_multicast = upnp_bind_multicast - self._api_server = None + self._api_server: EmulatedRokuServer | None = None - self._unsub_start_listener = None - self._unsub_stop_listener = None + self._unsub_start_listener: CALLBACK_TYPE | None = None + self._unsub_stop_listener: CALLBACK_TYPE | None = None - async def setup(self): + async def setup(self) -> bool: """Start the emulated_roku server.""" class EventCommandHandler(EmulatedRokuCommandHandler): """emulated_roku command handler to turn commands into events.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: self.hass = hass - def on_keydown(self, roku_usn, key): + def on_keydown(self, roku_usn: str, key: str) -> None: """Handle keydown event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -73,7 +81,7 @@ class EmulatedRoku: EventOrigin.local, ) - def on_keyup(self, roku_usn, key): + def on_keyup(self, roku_usn: str, key: str) -> None: """Handle keyup event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -85,7 +93,7 @@ class EmulatedRoku: EventOrigin.local, ) - def on_keypress(self, roku_usn, key): + def on_keypress(self, roku_usn: str, key: str) -> None: """Handle keypress event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -97,7 +105,7 @@ class EmulatedRoku: EventOrigin.local, ) - def launch(self, roku_usn, app_id): + def launch(self, roku_usn: str, app_id: str) -> None: """Handle launch event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -129,17 +137,19 @@ class EmulatedRoku: bind_multicast=self.bind_multicast, ) - async def emulated_roku_stop(event): + async def emulated_roku_stop(event: Event | None) -> None: """Wrap the call to emulated_roku.close.""" LOGGER.debug("Stopping emulated_roku %s", self.roku_usn) self._unsub_stop_listener = None + assert self._api_server is not None await self._api_server.close() - async def emulated_roku_start(event): + async def emulated_roku_start(event: Event | None) -> None: """Wrap the call to emulated_roku.start.""" try: LOGGER.debug("Starting emulated_roku %s", self.roku_usn) self._unsub_start_listener = None + assert self._api_server is not None await self._api_server.start() except OSError: LOGGER.exception( @@ -165,7 +175,7 @@ class EmulatedRoku: return True - async def unload(self): + async def unload(self) -> bool: """Unload the emulated_roku server.""" LOGGER.debug("Unloading emulated_roku %s", self.roku_usn) @@ -177,6 +187,7 @@ class EmulatedRoku: self._unsub_stop_listener() self._unsub_stop_listener = None + assert self._api_server is not None await self._api_server.close() return True diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index ff86177cf41..442aedf23b0 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -139,6 +139,10 @@ class DeviceConsumption(TypedDict): # An optional custom name for display in energy graphs name: str | None + # An optional statistic_id identifying a device + # that includes this device's consumption in its total + included_in_stat: str | None + class EnergyPreferences(TypedDict): """Dictionary holding the energy data.""" @@ -291,6 +295,7 @@ DEVICE_CONSUMPTION_SCHEMA = vol.Schema( { vol.Required("stat_consumption"): str, vol.Optional("name"): str, + vol.Optional("included_in_stat"): str, } ) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index eec92c32f98..062601eb4c5 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -25,6 +25,7 @@ from homeassistant.core import ( split_entity_id, valid_entity_id, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -122,6 +123,10 @@ SOURCE_ADAPTERS: Final = ( ) +class EntityNotFoundError(HomeAssistantError): + """When a referenced entity was not found.""" + + class SensorManager: """Class to handle creation/removal of sensor data.""" @@ -311,43 +316,25 @@ class EnergyCostSensor(SensorEntity): except ValueError: return - # Determine energy price - if self._config["entity_energy_price"] is not None: - energy_price_state = self.hass.states.get( - self._config["entity_energy_price"] + try: + energy_price, energy_price_unit = self._get_energy_price( + valid_units, default_price_unit ) - - if energy_price_state is None: - return - - try: - energy_price = float(energy_price_state.state) - except ValueError: - if self._last_energy_sensor_state is None: - # Initialize as it's the first time all required entities except - # price are in place. This means that the cost will update the first - # time the energy is updated after the price entity is in place. - self._reset(energy_state) - return - - energy_price_unit: str | None = energy_price_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, "" - ).partition("/")[2] - - # For backwards compatibility we don't validate the unit of the price - # If it is not valid, we assume it's our default price unit. - if energy_price_unit not in valid_units: - energy_price_unit = default_price_unit - - else: - energy_price = cast(float, self._config["number_energy_price"]) - energy_price_unit = default_price_unit + except EntityNotFoundError: + return + except ValueError: + energy_price = None if self._last_energy_sensor_state is None: - # Initialize as it's the first time all required entities are in place. + # Initialize as it's the first time all required entities are in place or + # only the price is missing. In the later case, cost will update the first + # time the energy is updated after the price entity is in place. self._reset(energy_state) return + if energy_price is None: + return + energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if energy_unit is None or energy_unit not in valid_units: @@ -383,20 +370,9 @@ class EnergyCostSensor(SensorEntity): old_energy_value = float(self._last_energy_sensor_state.state) cur_value = cast(float, self._attr_native_value) - if energy_price_unit is None: - converted_energy_price = energy_price - else: - converter: Callable[[float, str, str], float] - if energy_unit in VALID_ENERGY_UNITS: - converter = unit_conversion.EnergyConverter.convert - else: - converter = unit_conversion.VolumeConverter.convert - - converted_energy_price = converter( - energy_price, - energy_unit, - energy_price_unit, - ) + converted_energy_price = self._convert_energy_price( + energy_price, energy_price_unit, energy_unit + ) self._attr_native_value = ( cur_value + (energy - old_energy_value) * converted_energy_price @@ -404,6 +380,52 @@ class EnergyCostSensor(SensorEntity): self._last_energy_sensor_state = energy_state + def _get_energy_price( + self, valid_units: set[str], default_unit: str | None + ) -> tuple[float, str | None]: + """Get the energy price. + + Raises: + EntityNotFoundError: When the energy price entity is not found. + ValueError: When the entity state is not a valid float. + + """ + + if self._config["entity_energy_price"] is None: + return cast(float, self._config["number_energy_price"]), default_unit + + energy_price_state = self.hass.states.get(self._config["entity_energy_price"]) + if energy_price_state is None: + raise EntityNotFoundError + + energy_price = float(energy_price_state.state) + + energy_price_unit: str | None = energy_price_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, "" + ).partition("/")[2] + + # For backwards compatibility we don't validate the unit of the price + # If it is not valid, we assume it's our default price unit. + if energy_price_unit not in valid_units: + energy_price_unit = default_unit + + return energy_price, energy_price_unit + + def _convert_energy_price( + self, energy_price: float, energy_price_unit: str | None, energy_unit: str + ) -> float: + """Convert the energy price to the correct unit.""" + if energy_price_unit is None: + return energy_price + + converter: Callable[[float, str, str], float] + if energy_unit in VALID_ENERGY_UNITS: + converter = unit_conversion.EnergyConverter.convert + else: + converter = unit_conversion.VolumeConverter.convert + + return converter(energy_price, energy_unit, energy_price_unit) + async def async_added_to_hass(self) -> None: """Register callbacks.""" energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key]) diff --git a/homeassistant/components/energy/strings.json b/homeassistant/components/energy/strings.json index e9d72247319..5eb2c93161e 100644 --- a/homeassistant/components/energy/strings.json +++ b/homeassistant/components/energy/strings.json @@ -7,7 +7,7 @@ }, "recorder_untracked": { "title": "Entity not tracked", - "description": "The recorder has been configured to exclude these configured entities:" + "description": "Home Assistant Recorder has been configured to exclude these configured entities:" }, "entity_unavailable": { "title": "Entity unavailable", diff --git a/homeassistant/components/enigma2/config_flow.py b/homeassistant/components/enigma2/config_flow.py index b0649a8368d..876d55128cf 100644 --- a/homeassistant/components/enigma2/config_flow.py +++ b/homeassistant/components/enigma2/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Enigma2.""" +import logging from typing import Any, cast from aiohttp.client_exceptions import ClientError @@ -63,6 +64,8 @@ CONFIG_SCHEMA = vol.Schema( } ) +_LOGGER = logging.getLogger(__name__) + async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: """Get the options schema.""" @@ -130,7 +133,8 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): errors = {"base": "invalid_auth"} except ClientError: errors = {"base": "cannot_connect"} - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors = {"base": "unknown"} else: unique_id = about["info"]["ifaces"][0]["mac"] or self.unique_id diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index d5b3880cf24..80eed76574f 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -66,16 +66,19 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]: ] for end_point in end_points: - response = await envoy.request(end_point) - fixture_data[end_point] = response.text.replace("\n", "").replace( - serial, CLEAN_TEXT - ) - fixture_data[f"{end_point}_log"] = json_dumps( - { - "headers": dict(response.headers.items()), - "code": response.status_code, - } - ) + try: + response = await envoy.request(end_point) + fixture_data[end_point] = response.text.replace("\n", "").replace( + serial, CLEAN_TEXT + ) + fixture_data[f"{end_point}_log"] = json_dumps( + { + "headers": dict(response.headers.items()), + "code": response.status_code, + } + ) + except EnvoyError as err: + fixture_data[f"{end_point}_log"] = {"Error": repr(err)} return fixture_data @@ -160,10 +163,7 @@ async def async_get_config_entry_diagnostics( fixture_data: dict[str, Any] = {} if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False): - try: - fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial) - except EnvoyError as err: - fixture_data["Error"] = repr(err) + fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial) diagnostic_data: dict[str, Any] = { "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index e51a7427504..88183fe4cfd 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.25.1"], + "requirements": ["pyenphase==1.25.5"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index b498c59e0d3..ce3a8593226 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -187,13 +187,13 @@ "name": "Lifetime energy consumption {phase_name}" }, "balanced_net_consumption": { - "name": "balanced net power consumption" + "name": "Balanced net power consumption" }, "lifetime_balanced_net_consumption": { "name": "Lifetime balanced net energy consumption" }, "balanced_net_consumption_phase": { - "name": "balanced net power consumption {phase_name}" + "name": "Balanced net power consumption {phase_name}" }, "lifetime_balanced_net_consumption_phase": { "name": "Lifetime balanced net energy consumption {phase_name}" @@ -217,7 +217,7 @@ "name": "Net consumption CT current" }, "net_ct_powerfactor": { - "name": "Powerfactor net consumption CT" + "name": "Power factor net consumption CT" }, "net_ct_metering_status": { "name": "Metering status net consumption CT" @@ -235,7 +235,7 @@ "name": "Production CT current" }, "production_ct_powerfactor": { - "name": "powerfactor production CT" + "name": "Power factor production CT" }, "production_ct_metering_status": { "name": "Metering status production CT" @@ -262,7 +262,7 @@ "name": "Storage CT current" }, "storage_ct_powerfactor": { - "name": "Powerfactor storage CT" + "name": "Power factor storage CT" }, "storage_ct_metering_status": { "name": "Metering status storage CT" @@ -289,7 +289,7 @@ "name": "Net consumption CT current {phase_name}" }, "net_ct_powerfactor_phase": { - "name": "Powerfactor net consumption CT {phase_name}" + "name": "Power factor net consumption CT {phase_name}" }, "net_ct_metering_status_phase": { "name": "Metering status net consumption CT {phase_name}" @@ -307,7 +307,7 @@ "name": "Production CT current {phase_name}" }, "production_ct_powerfactor_phase": { - "name": "Powerfactor production CT {phase_name}" + "name": "Power factor production CT {phase_name}" }, "production_ct_metering_status_phase": { "name": "Metering status production CT {phase_name}" @@ -334,7 +334,7 @@ "name": "Storage CT current {phase_name}" }, "storage_ct_powerfactor_phase": { - "name": "Powerfactor storage CT {phase_name}" + "name": "Power factor storage CT {phase_name}" }, "storage_ct_metering_status_phase": { "name": "Metering status storage CT {phase_name}" diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 3a789289c74..1685888d2bc 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -168,6 +168,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, value_fn=lambda data: data.conditions.get("wind_bearing", {}).get("value"), device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), ECSensorEntityDescription( key="wind_chill", diff --git a/homeassistant/components/epic_games_store/strings.json b/homeassistant/components/epic_games_store/strings.json index 58a87a55f81..ab4562a72ad 100644 --- a/homeassistant/components/epic_games_store/strings.json +++ b/homeassistant/components/epic_games_store/strings.json @@ -3,8 +3,8 @@ "step": { "user": { "data": { - "language": "Language", - "country": "Country" + "language": "[%key:common::config_flow::data::language%]", + "country": "[%key:common::config_flow::data::country%]" } } }, diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index ab62c962982..d99de32b09c 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.12.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.13.1"] } diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index fdd16d20d77..9d92b5fcb92 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -253,6 +253,11 @@ class EsphomeAssistSatellite( # Will use media player for TTS/announcements self._update_tts_format() + if feature_flags & VoiceAssistantFeature.START_CONVERSATION: + self._attr_supported_features |= ( + assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + # Update wake word select when config is updated self.async_on_remove( self.entry_data.async_register_assist_satellite_set_wake_word_callback( @@ -305,12 +310,13 @@ class EsphomeAssistSatellite( self.entry_data.api_version ) ) - if feature_flags & VoiceAssistantFeature.SPEAKER: - media_id = tts_output["media_id"] + if feature_flags & VoiceAssistantFeature.SPEAKER and ( + stream := tts.async_get_stream(self.hass, tts_output["token"]) + ): self._tts_streaming_task = ( self.config_entry.async_create_background_task( self.hass, - self._stream_tts_audio(media_id), + self._stream_tts_audio(stream), "esphome_voice_assistant_tts", ) ) @@ -342,14 +348,33 @@ class EsphomeAssistSatellite( Should block until the announcement is done playing. """ + await self._do_announce(announcement, run_pipeline_after=False) + + async def async_start_conversation( + self, start_announcement: assist_satellite.AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + await self._do_announce(start_announcement, run_pipeline_after=True) + + async def _do_announce( + self, + announcement: assist_satellite.AssistSatelliteAnnouncement, + run_pipeline_after: bool, + ) -> None: + """Announce media on the satellite. + + Optionally run a voice pipeline after the announcement has finished. + """ _LOGGER.debug( "Waiting for announcement to finished (message=%s, media_id=%s)", announcement.message, announcement.media_id, ) media_id = announcement.media_id - if announcement.media_id_source != "tts": - # Route non-TTS media through the proxy + is_media_tts = announcement.media_id_source == "tts" + preannounce_media_id = announcement.preannounce_media_id + if (not is_media_tts) or preannounce_media_id: + # Route media through the proxy format_to_use: MediaPlayerSupportedFormat | None = None for supported_format in chain( *self.entry_data.media_player_formats.values() @@ -362,19 +387,33 @@ class EsphomeAssistSatellite( assert (self.registry_entry is not None) and ( self.registry_entry.device_id is not None ) - proxy_url = async_create_proxy_url( - self.hass, - self.registry_entry.device_id, - media_id, + + make_proxy_url = partial( + async_create_proxy_url, + hass=self.hass, + device_id=self.registry_entry.device_id, media_format=format_to_use.format, rate=format_to_use.sample_rate or None, channels=format_to_use.num_channels or None, width=format_to_use.sample_bytes or None, ) - media_id = async_process_play_media_url(self.hass, proxy_url) + + if not is_media_tts: + media_id = async_process_play_media_url( + self.hass, make_proxy_url(media_url=media_id) + ) + + if preannounce_media_id: + preannounce_media_id = async_process_play_media_url( + self.hass, make_proxy_url(media_url=preannounce_media_id) + ) await self.cli.send_voice_assistant_announcement_await_response( - media_id, _ANNOUNCEMENT_TIMEOUT_SEC, announcement.message + media_id, + _ANNOUNCEMENT_TIMEOUT_SEC, + announcement.message, + start_conversation=run_pipeline_after, + preannounce_media_id=preannounce_media_id or "", ) async def handle_pipeline_start( @@ -526,7 +565,7 @@ class EsphomeAssistSatellite( async def _stream_tts_audio( self, - media_id: str, + tts_result: tts.ResultStream, sample_rate: int = 16000, sample_width: int = 2, sample_channels: int = 1, @@ -541,15 +580,14 @@ class EsphomeAssistSatellite( if not self._is_running: return - extension, data = await tts.async_get_media_source_audio( - self.hass, - media_id, - ) - - if extension != "wav": - _LOGGER.error("Only WAV audio can be streamed, got %s", extension) + if tts_result.extension != "wav": + _LOGGER.error( + "Only WAV audio can be streamed, got %s", tts_result.extension + ) return + data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: if ( (wav_file.getframerate() != sample_rate) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 955a93cd2b7..686d77d9b34 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -128,8 +128,23 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._password = "" return await self._async_authenticate_or_add() + if error is None and entry_data.get(CONF_NOISE_PSK): + return await self.async_step_reauth_encryption_removed_confirm() return await self.async_step_reauth_confirm() + async def async_step_reauth_encryption_removed_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthorization flow when encryption was removed.""" + if user_input is not None: + self._noise_psk = None + return self._async_get_entry() + + return self.async_show_form( + step_id="reauth_encryption_removed_confirm", + description_placeholders={"name": self._name}, + ) + async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index fc41ee99a00..023c6f70da4 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -282,15 +282,18 @@ class RuntimeEntryData: ) -> None: """Distribute an update of static infos to all platforms.""" # First, load all platforms - needed_platforms = set() - if async_get_dashboard(hass): - needed_platforms.add(Platform.UPDATE) + needed_platforms: set[Platform] = set() - if self.device_info and self.device_info.voice_assistant_feature_flags_compat( - self.api_version - ): - needed_platforms.add(Platform.BINARY_SENSOR) - needed_platforms.add(Platform.SELECT) + if self.device_info: + if async_get_dashboard(hass): + # Only load the update platform if the device_info is set + # When we restore the entry, the device_info may not be set yet + # and we don't want to load the update platform since it needs + # a complete device_info. + needed_platforms.add(Platform.UPDATE) + if self.device_info.voice_assistant_feature_flags_compat(self.api_version): + needed_platforms.add(Platform.BINARY_SENSOR) + needed_platforms.add(Platform.SELECT) ent_reg = er.async_get(hass) registry_get_entity = ent_reg.async_get_entity_id @@ -312,18 +315,19 @@ class RuntimeEntryData: # Make a dict of the EntityInfo by type and send # them to the listeners for each specific EntityInfo type - infos_by_type: dict[type[EntityInfo], list[EntityInfo]] = {} + infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict( + list + ) for info in infos: - info_type = type(info) - if info_type not in infos_by_type: - infos_by_type[info_type] = [] - infos_by_type[info_type].append(info) + infos_by_type[type(info)].append(info) - callbacks_by_type = self.entity_info_callbacks - for type_, entity_infos in infos_by_type.items(): - if callbacks_ := callbacks_by_type.get(type_): - for callback_ in callbacks_: - callback_(entity_infos) + for type_, callbacks in self.entity_info_callbacks.items(): + # If all entities for a type are removed, we + # still need to call the callbacks with an empty list + # to make sure the entities are removed. + entity_infos = infos_by_type.get(type_, []) + for callback_ in callbacks: + callback_(entity_infos) # Finally update static info subscriptions for callback_ in self.static_info_update_subscriptions: diff --git a/homeassistant/components/esphome/event.py b/homeassistant/components/esphome/event.py index 11a5d0cfb33..f4db3844e3d 100644 --- a/homeassistant/components/esphome/event.py +++ b/homeassistant/components/esphome/event.py @@ -33,6 +33,16 @@ class EsphomeEvent(EsphomeEntity[EventInfo, Event], EventEntity): self._trigger_event(self._state.event_type) self.async_write_ha_state() + @callback + def _on_device_update(self) -> None: + """Call when device updates or entry data changes.""" + super()._on_device_update() + if self._entry_data.available: + # Event entities should go available directly + # when the device comes online and not wait + # for the next data push. + self.async_write_ha_state() + async_setup_entry = partial( platform_async_setup_entry, diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 0a47fb66815..56c2998a3cc 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -13,6 +13,7 @@ from aioesphomeapi import ( APIConnectionError, APIVersion, DeviceInfo as EsphomeDeviceInfo, + EncryptionPlaintextAPIError, EntityInfo, HomeassistantServiceCall, InvalidAuthAPIError, @@ -570,6 +571,7 @@ class ESPHomeManager: if isinstance( err, ( + EncryptionPlaintextAPIError, RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError, InvalidAuthAPIError, diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 075185dffbb..9f6431c940f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,9 +16,9 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.7.0", + "aioesphomeapi==29.9.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.12.0" + "bleak-esphome==2.13.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 1534a49e365..437b9ac2098 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -4,7 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "mdns_missing_mac": "Missing MAC address in MDNS properties.", + "mdns_missing_mac": "Missing MAC address in mDNS properties.", "service_received": "Action received", "mqtt_missing_mac": "Missing MAC address in MQTT properties.", "mqtt_missing_api": "Missing API port in MQTT properties.", @@ -43,6 +43,9 @@ }, "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your device configuration." }, + "reauth_encryption_removed_confirm": { + "description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." + }, "discovery_confirm": { "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", "title": "Discovered ESPHome node" diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py index ae159d77240..c153f01e83c 100644 --- a/homeassistant/components/everlights/light.py +++ b/homeassistant/components/everlights/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any +from typing import Any, cast import pyeverlights import voluptuous as vol @@ -84,7 +84,7 @@ class EverLightsLight(LightEntity): api: pyeverlights.EverLights, channel: int, status: dict[str, Any], - effects, + effects: list[str], ) -> None: """Initialize the light.""" self._api = api @@ -106,8 +106,10 @@ class EverLightsLight(LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) - brightness = kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness) + hs_color = cast( + tuple[float, float], kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) + ) + brightness = cast(int, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness)) effect = kwargs.get(ATTR_EFFECT) if effect is not None: @@ -116,7 +118,7 @@ class EverLightsLight(LightEntity): rgb = color_int_to_rgb(colors[0]) hsv = color_util.color_RGB_to_hsv(*rgb) hs_color = hsv[:2] - brightness = hsv[2] / 100 * 255 + brightness = round(hsv[2] / 100 * 255) else: rgb = color_util.color_hsv_to_RGB( diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index b44dc9791b0..40439c1eb02 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -152,7 +152,7 @@ class EvoZone(EvoChild, EvoClimateEntity): super().__init__(coordinator, evo_device) self._evo_id = evo_device.id - if evo_device.model.startswith("VisionProWifi"): + if evo_device.id == evo_device.tcs.id: # this system does not have a distinct ID for the zone self._attr_unique_id = f"{evo_device.id}z" else: diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 44e4cdb1128..21c8874135a 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==1.0.4"] + "requirements": ["evohome-async==1.0.5"] } diff --git a/homeassistant/components/feedreader/strings.json b/homeassistant/components/feedreader/strings.json index 3132aadbda8..35022e82bb1 100644 --- a/homeassistant/components/feedreader/strings.json +++ b/homeassistant/components/feedreader/strings.json @@ -36,7 +36,7 @@ "issues": { "import_yaml_error_url_error": { "title": "The Feedreader YAML configuration import failed", - "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." + "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that the URL is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." } } } diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 33b2598a636..88288a86b59 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -14,9 +14,10 @@ from pyfibaro.fibaro_client import ( ) from pyfibaro.fibaro_data_helper import read_rooms from pyfibaro.fibaro_device import DeviceModel +from pyfibaro.fibaro_device_manager import FibaroDeviceManager from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel -from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver +from pyfibaro.fibaro_state_resolver import FibaroEvent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform @@ -81,8 +82,8 @@ class FibaroController: self._client = fibaro_client self._fibaro_info = info - # Whether to import devices from plugins - self._import_plugins = import_plugins + # The fibaro device manager exposes higher level API to access fibaro devices + self._fibaro_device_manager = FibaroDeviceManager(fibaro_client, import_plugins) # Mapping roomId to room object self._room_map = read_rooms(fibaro_client) self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object @@ -91,79 +92,30 @@ class FibaroController: ) # List of devices by entity platform # All scenes self._scenes = self._client.read_scenes() - self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId - # Event callbacks by device id - self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} # Unique serial number of the hub self.hub_serial = info.serial_number # Device infos by fibaro device id self._device_infos: dict[int, DeviceInfo] = {} self._read_devices() - def enable_state_handler(self) -> None: - """Start StateHandler thread for monitoring updates.""" - self._client.register_update_handler(self._on_state_change) + def disconnect(self) -> None: + """Close push channel.""" + self._fibaro_device_manager.close() - def disable_state_handler(self) -> None: - """Stop StateHandler thread used for monitoring updates.""" - self._client.unregister_update_handler() - - def _on_state_change(self, state: Any) -> None: - """Handle change report received from the HomeCenter.""" - callback_set = set() - for change in state.get("changes", []): - try: - dev_id = change.pop("id") - if dev_id not in self._device_map: - continue - device = self._device_map[dev_id] - for property_name, value in change.items(): - if property_name == "log": - if value and value != "transfer OK": - _LOGGER.debug("LOG %s: %s", device.friendly_name, value) - continue - if property_name == "logTemp": - continue - if property_name in device.properties: - device.properties[property_name] = value - _LOGGER.debug( - "<- %s.%s = %s", device.ha_id, property_name, str(value) - ) - else: - _LOGGER.warning("%s.%s not found", device.ha_id, property_name) - if dev_id in self._callbacks: - callback_set.add(dev_id) - except (ValueError, KeyError): - pass - for item in callback_set: - for callback in self._callbacks[item]: - callback() - - resolver = FibaroStateResolver(state) - for event in resolver.get_events(): - # event does not always have a fibaro id, therefore it is - # essential that we first check for relevant event type - if ( - event.event_type.lower() == "centralsceneevent" - and event.fibaro_id in self._event_callbacks - ): - for callback in self._event_callbacks[event.fibaro_id]: - callback(event) - - def register(self, device_id: int, callback: Any) -> None: + def register( + self, device_id: int, callback: Callable[[DeviceModel], None] + ) -> Callable[[], None]: """Register device with a callback for updates.""" - device_callbacks = self._callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_change_listener(device_id, callback) def register_event( self, device_id: int, callback: Callable[[FibaroEvent], None] - ) -> None: + ) -> Callable[[], None]: """Register device with a callback for central scene events. The callback receives one parameter with the event. """ - device_callbacks = self._event_callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_event_listener(device_id, callback) def get_children(self, device_id: int) -> list[DeviceModel]: """Get a list of child devices.""" @@ -286,7 +238,7 @@ class FibaroController: def _read_devices(self) -> None: """Read and process the device list.""" - devices = self._client.read_devices() + devices = self._fibaro_device_manager.get_devices() self._device_map = {} last_climate_parent = None last_endpoint = None @@ -301,8 +253,8 @@ class FibaroController: device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" ) - if device.enabled and (not device.is_plugin or self._import_plugins): - platform = self._map_device_to_platform(device) + + platform = self._map_device_to_platform(device) if platform is None: continue device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}" @@ -392,8 +344,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - controller.enable_state_handler() - return True @@ -402,8 +352,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> b _LOGGER.debug("Shutting down Fibaro connection") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - entry.runtime_data.disable_state_handler() - + entry.runtime_data.disconnect() return unload_ok diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py index 5375b058315..e8ed5afc500 100644 --- a/homeassistant/components/fibaro/entity.py +++ b/homeassistant/components/fibaro/entity.py @@ -36,9 +36,13 @@ class FibaroEntity(Entity): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" - self.controller.register(self.fibaro_device.fibaro_id, self._update_callback) + self.async_on_remove( + self.controller.register( + self.fibaro_device.fibaro_id, self._update_callback + ) + ) - def _update_callback(self) -> None: + def _update_callback(self, fibaro_device: DeviceModel) -> None: """Update the state.""" self.schedule_update_ha_state(True) diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index 0beea2e336e..ad44719c8be 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -60,11 +60,16 @@ class FibaroEventEntity(FibaroEntity, EventEntity): await super().async_added_to_hass() # Register event callback - self.controller.register_event( - self.fibaro_device.fibaro_id, self._event_callback + self.async_on_remove( + self.controller.register_event( + self.fibaro_device.fibaro_id, self._event_callback + ) ) def _event_callback(self, event: FibaroEvent) -> None: - if event.key_id == self._button: + if ( + event.event_type.lower() == "centralsceneevent" + and event.key_id == self._button + ): self._trigger_event(event.key_event_type) self.schedule_update_ha_state() diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 318325dbb09..f5188d5bf21 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections import namedtuple from datetime import timedelta import logging -from typing import Any +from typing import Any, cast from fints.client import FinTS3PinTanClient from fints.models import SEPAAccount @@ -73,7 +73,7 @@ def setup_platform( credentials = BankCredentials( config[CONF_BIN], config[CONF_USERNAME], config[CONF_PIN], config[CONF_URL] ) - fints_name = config.get(CONF_NAME, config[CONF_BIN]) + fints_name = cast(str, config.get(CONF_NAME, config[CONF_BIN])) account_config = { acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_ACCOUNTS] diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index fcb16c9742b..2c5e1b3839e 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -53,5 +53,5 @@ "documentation": "https://www.home-assistant.io/integrations/flux_led", "iot_class": "local_push", "loggers": ["flux_led"], - "requirements": ["flux-led==1.1.3"] + "requirements": ["flux-led==1.2.0"] } diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 1eb9c98701d..769bda56adc 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/forecast_solar", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["forecast-solar==4.0.0"] + "requirements": ["forecast-solar==4.1.0"] } diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 6bc8bb571d4..2a4eb8c82b5 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class FritzBinarySensorEntityDescription( diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 74e8ab5e43e..4a5f7e5a443 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -31,6 +31,9 @@ from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + @dataclass(frozen=True, kw_only=True) class FritzButtonDescription(ButtonEntityDescription): diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index e066219342e..618214a1c55 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -22,6 +22,9 @@ from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index d329ec318c5..1fc70dedc6c 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -18,6 +18,9 @@ from .entity import FritzBoxBaseEntity _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 805705eb4b4..29e46b3a0c9 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -14,9 +14,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: - status: todo - comment: include the proper docs snippet + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: @@ -31,15 +29,11 @@ rules: action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done - docs-installation-parameters: - status: todo - comment: add the proper configuration_basic block + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: todo - comment: not set at the moment, we use a coordinator + parallel-updates: done reauthentication-flow: done test-coverage: status: todo @@ -50,7 +44,7 @@ rules: diagnostics: done discovery-update-info: todo discovery: done - docs-data-update: todo + docs-data-update: done docs-examples: done docs-known-limitations: status: exempt diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index bcee590460f..65a776b9ad5 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -32,6 +32,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime: """Calculate uptime with deviation.""" @@ -193,7 +196,6 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="max_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_max_kb_s_sent_state, ), FritzSensorEntityDescription( @@ -201,7 +203,6 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="max_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_max_kb_s_received_state, ), FritzSensorEntityDescription( @@ -225,6 +226,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="link_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_link_kb_s_sent_state, ), FritzSensorEntityDescription( @@ -232,12 +234,15 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="link_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_link_kb_s_received_state, ), FritzSensorEntityDescription( key="link_noise_margin_sent", translation_key="link_noise_margin_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_noise_margin_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -245,6 +250,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( key="link_noise_margin_received", translation_key="link_noise_margin_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_noise_margin_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -252,6 +259,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( key="link_attenuation_sent", translation_key="link_attenuation_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_attenuation_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -259,6 +268,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( key="link_attenuation_received", translation_key="link_attenuation_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_attenuation_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 8b4816f7451..c00849c5240 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -38,6 +38,9 @@ from .entity import FritzBoxBaseEntity, FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + async def _async_deflection_entities_list( avm_wrapper: AvmWrapper, device_friendly_name: str diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 5d064dc3035..4e54f4c28d3 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + @dataclass(frozen=True, kw_only=True) class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 118e03c391f..57c7e2a696f 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -6,6 +6,7 @@ from typing import Any from homeassistant.components.climate import ( ATTR_HVAC_MODE, + PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, ClimateEntity, @@ -38,7 +39,7 @@ from .sensor import value_scheduled_preset HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF] PRESET_HOLIDAY = "holiday" PRESET_SUMMER = "summer" -PRESET_MODES = [PRESET_ECO, PRESET_COMFORT] +PRESET_MODES = [PRESET_ECO, PRESET_COMFORT, PRESET_BOOST] SUPPORTED_FEATURES = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE @@ -194,6 +195,8 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): return PRESET_HOLIDAY if self.data.summer_active: return PRESET_SUMMER + if self.data.target_temperature == ON_API_TEMPERATURE: + return PRESET_BOOST if self.data.target_temperature == self.data.comfort_temperature: return PRESET_COMFORT if self.data.target_temperature == self.data.eco_temperature: @@ -211,6 +214,8 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): await self.async_set_temperature(temperature=self.data.comfort_temperature) elif preset_mode == PRESET_ECO: await self.async_set_temperature(temperature=self.data.eco_temperature) + elif preset_mode == PRESET_BOOST: + await self.async_set_temperature(temperature=ON_REPORT_SET_TEMPERATURE) @property def extra_state_attributes(self) -> ClimateExtraAttributes: diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index bed7004bd6a..801a3a67a6e 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -137,6 +137,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, suitable=lambda device: device.battery_level is not None, native_value=lambda device: device.battery_level, diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py index f35c9ce5bc1..b8aa2da81c6 100644 --- a/homeassistant/components/fronius/config_flow.py +++ b/homeassistant/components/fronius/config_flow.py @@ -149,7 +149,7 @@ class FroniusConfigFlow(ConfigFlow, domain=DOMAIN): unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index b77f6fec83c..36778f2ca5f 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -182,10 +182,10 @@ "state": { "startup": "Startup", "running": "Running", - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "bootloading": "Bootloading", "error": "Error", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "ready": "Ready", "sleeping": "Sleeping" } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b210fdb6661..140d90c5dbe 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==20250306.0"] + "requirements": ["home-assistant-frontend==20250404.0"] } diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index f6514da28ff..dc4f6bea989 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -108,8 +108,8 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) except FSConnectionError: return self.async_abort(reason="cannot_connect") - except Exception as exception: # noqa: BLE001 - _LOGGER.debug(exception) + except Exception: + _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") # try to login with default pin diff --git a/homeassistant/components/fujitsu_fglair/config_flow.py b/homeassistant/components/fujitsu_fglair/config_flow.py index c4b097ff0de..9369fd7b7cd 100644 --- a/homeassistant/components/fujitsu_fglair/config_flow.py +++ b/homeassistant/components/fujitsu_fglair/config_flow.py @@ -62,7 +62,7 @@ class FGLairConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except AylaAuthError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index 5841456c034..fdfdf7910ae 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -2,7 +2,7 @@ "common": { "data_description_password": "The Remote Admin password from the Fully Kiosk Browser app settings.", "data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?", - "data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates." + "data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates." }, "config": { "step": { diff --git a/homeassistant/components/fyta/config_flow.py b/homeassistant/components/fyta/config_flow.py index 78cb7647785..9c5ab1de405 100644 --- a/homeassistant/components/fyta/config_flow.py +++ b/homeassistant/components/fyta/config_flow.py @@ -65,8 +65,8 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): return {"base": "invalid_auth"} except FytaPasswordError: return {"base": "invalid_auth", CONF_PASSWORD: "password_error"} - except Exception as e: # noqa: BLE001 - _LOGGER.error(e) + except Exception: + _LOGGER.exception("Unexpected exception") return {"base": "unknown"} finally: await fyta.client.close() diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 1a25f654e19..f595b66ee37 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -79,9 +79,9 @@ "state": { "no_data": "No data", "too_low": "Too low", - "low": "Low", + "low": "[%key:common::state::low%]", "perfect": "Perfect", - "high": "High", + "high": "[%key:common::state::high%]", "too_high": "Too high" } }, @@ -90,9 +90,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -101,9 +101,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -112,9 +112,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -123,9 +123,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, diff --git a/homeassistant/components/gaggenau/__init__.py b/homeassistant/components/gaggenau/__init__.py new file mode 100644 index 00000000000..2c03410c35d --- /dev/null +++ b/homeassistant/components/gaggenau/__init__.py @@ -0,0 +1 @@ +"""Gaggenau virtual integration.""" diff --git a/homeassistant/components/gaggenau/manifest.json b/homeassistant/components/gaggenau/manifest.json new file mode 100644 index 00000000000..9dc38b2e4b3 --- /dev/null +++ b/homeassistant/components/gaggenau/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "gaggenau", + "name": "Gaggenau", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 190caa58b3f..185040f02c9 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -539,10 +539,14 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return assert self._cur_temp is not None and self._target_temp is not None - too_cold = self._target_temp >= self._cur_temp + self._cold_tolerance - too_hot = self._cur_temp >= self._target_temp + self._hot_tolerance + + min_temp = self._target_temp - self._cold_tolerance + max_temp = self._target_temp + self._hot_tolerance + if self._is_device_active: - if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot): + if (self.ac_mode and self._cur_temp <= min_temp) or ( + not self.ac_mode and self._cur_temp >= max_temp + ): _LOGGER.debug("Turning off heater %s", self.heater_entity_id) await self._async_heater_turn_off() elif time is not None: @@ -552,7 +556,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self.heater_entity_id, ) await self._async_heater_turn_on() - elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold): + elif (self.ac_mode and self._cur_temp > max_temp) or ( + not self.ac_mode and self._cur_temp < min_temp + ): _LOGGER.debug("Turning on heater %s", self.heater_entity_id) await self._async_heater_turn_on() elif time is not None: diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index 58280e99543..735e0b0f9e6 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -21,17 +21,17 @@ "heater": "Switch entity used to cool or heat depending on A/C mode.", "target_sensor": "Temperature sensor that reflects the current temperature.", "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.", - "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.", + "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.", "hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5." } }, "presets": { "title": "Temperature presets", "data": { - "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "home_temp": "[%key:common::state::home%]", + "away_temp": "[%key:common::state::not_home%]", "comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "home_temp": "[%key:common::state::home%]", "sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]" } @@ -63,10 +63,10 @@ "presets": { "title": "[%key:component::generic_thermostat::config::step::presets::title%]", "data": { - "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "home_temp": "[%key:common::state::home%]", + "away_temp": "[%key:common::state::not_home%]", "comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "home_temp": "[%key:common::state::home%]", "sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]" } diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index 0348d0b428c..cebff656d5d 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses +import logging import re from typing import Any, Self @@ -27,6 +28,8 @@ from homeassistant.helpers.service_info.zeroconf import ( from .common import get_api from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN +_LOGGER = logging.getLogger(__name__) + DEVICE_NAMES = { DEVICE_TYPE_GOGOGATE2: "Gogogate2", DEVICE_TYPE_ISMARTGATE: "ismartgate", @@ -115,7 +118,8 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): else: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" if self._ip_address and self._device_type: diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 4f8ffba1d19..a62d2bf1d6b 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -89,6 +89,7 @@ OPAQUE = "opaque" RRULE_PREFIX = "RRULE:" SERVICE_CREATE_EVENT = "create_event" +FILTERED_EVENT_TYPES = [EventTypeEnum.BIRTHDAY, EventTypeEnum.WORKING_LOCATION] @dataclasses.dataclass(frozen=True, kw_only=True) @@ -103,7 +104,7 @@ class GoogleCalendarEntityDescription(CalendarEntityDescription): search: str | None local_sync: bool device_id: str - working_location: bool = False + event_type: EventTypeEnum | None = None def _get_entity_descriptions( @@ -173,14 +174,24 @@ def _get_entity_descriptions( local_sync, ) if calendar_item.primary and local_sync: - _LOGGER.debug("work location entity") + # Create a separate calendar for birthdays + entity_descriptions.append( + dataclasses.replace( + entity_description, + key=f"{key}-birthdays", + translation_key="birthdays", + event_type=EventTypeEnum.BIRTHDAY, + name=None, + entity_id=None, + ) + ) # Create an optional disabled by default entity for Work Location entity_descriptions.append( dataclasses.replace( entity_description, key=f"{key}-work-location", translation_key="working_location", - working_location=True, + event_type=EventTypeEnum.WORKING_LOCATION, name=None, entity_id=None, entity_registry_enabled_default=False, @@ -383,9 +394,18 @@ class GoogleCalendarEntity( for attendee in event.attendees ): return False - - if event.event_type == EventTypeEnum.WORKING_LOCATION: - return self.entity_description.working_location + # Calendar enttiy may be limited to a specific event type + if ( + self.entity_description.event_type is not None + and self.entity_description.event_type != event.event_type + ): + return False + # Default calendar entity omits the special types but includes all the others + if ( + self.entity_description.event_type is None + and event.event_type in FILTERED_EVENT_TYPES + ): + return False if self._ignore_availability: return True return event.transparency == OPAQUE diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 81fd2b07de4..2bedc7a3163 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.0.1"] + "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.1.0"] } diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 5ee0cdd9c14..5776fd0480b 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -131,6 +131,9 @@ "calendar": { "working_location": { "name": "Working location" + }, + "birthdays": { + "name": "Birthdays" } } } diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json index 85469a464b3..70e93f39f42 100644 --- a/homeassistant/components/google_assistant_sdk/manifest.json +++ b/homeassistant/components/google_assistant_sdk/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["gassist-text==0.0.11"], + "requirements": ["gassist-text==0.0.12"], "single_config_entry": true } diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json index 3e08b6254db..3e6371cbe23 100644 --- a/homeassistant/components/google_cloud/manifest.json +++ b/homeassistant/components/google_cloud/manifest.json @@ -8,7 +8,7 @@ "integration_type": "service", "iot_class": "cloud_push", "requirements": [ - "google-cloud-texttospeech==2.17.2", - "google-cloud-speech==2.27.0" + "google-cloud-texttospeech==2.25.1", + "google-cloud-speech==2.31.1" ] } diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index 41c5a6710b7..cd5055383ea 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -6,6 +6,7 @@ from collections.abc import AsyncGenerator, AsyncIterable import logging from google.api_core.exceptions import GoogleAPIError, Unauthenticated +from google.api_core.retry import AsyncRetry from google.cloud import speech_v1 from homeassistant.components.stt import ( @@ -127,6 +128,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): responses = await self._client.streaming_recognize( requests=request_generator(), timeout=10, + retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), ) transcript = "" diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 1f5f838b593..16519645dee 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any, cast from google.api_core.exceptions import GoogleAPIError, Unauthenticated +from google.api_core.retry import AsyncRetry from google.cloud import texttospeech import voluptuous as vol @@ -215,7 +216,11 @@ class BaseGoogleCloudProvider: ), ) - response = await self._client.synthesize_speech(request, timeout=10) + response = await self._client.synthesize_speech( + request, + timeout=10, + retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), + ) if encoding == texttospeech.AudioEncoding.MP3: extension = "mp3" diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index c32d7b5ddea..88a51446cda 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import mimetypes from pathlib import Path -from google import genai # type: ignore[attr-defined] +from google.genai import Client from google.genai.errors import APIError, ClientError from requests.exceptions import Timeout import voluptuous as vol @@ -43,7 +43,7 @@ CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = (Platform.CONVERSATION,) -type GoogleGenerativeAIConfigEntry = ConfigEntry[genai.Client] +type GoogleGenerativeAIConfigEntry = ConfigEntry[Client] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -139,7 +139,11 @@ async def async_setup_entry( """Set up Google Generative AI Conversation from a config entry.""" try: - client = genai.Client(api_key=entry.data[CONF_API_KEY]) + + def _init_client() -> Client: + return Client(api_key=entry.data[CONF_API_KEY]) + + client = await hass.async_add_executor_job(_init_client) await client.aio.models.get( model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), config={"http_options": {"timeout": TIMEOUT_MILLIS}}, diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 00a016143f4..ac6cb696a7d 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -7,7 +7,7 @@ import logging from types import MappingProxyType from typing import Any -from google import genai # type: ignore[attr-defined] +from google import genai from google.genai.errors import APIError, ClientError from requests.exceptions import Timeout import voluptuous as vol @@ -44,6 +44,7 @@ from .const import ( CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -51,6 +52,7 @@ from .const import ( RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, TIMEOUT_MILLIS, ) @@ -177,28 +179,30 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + errors: dict[str, str] = {} if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_LLM_HASS_API] == "none": user_input.pop(CONF_LLM_HASS_API) - return self.async_create_entry(title="", data=user_input) + if not ( + user_input.get(CONF_LLM_HASS_API, "none") != "none" + and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True + ): + # Don't allow to save options that enable the Google Seearch tool with an Assist API + return self.async_create_entry(title="", data=user_input) + errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option" # Re-render the options again, now with the recommended options shown/hidden self.last_rendered_recommended = user_input[CONF_RECOMMENDED] - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], - } + options = user_input schema = await google_generative_ai_config_option_schema( self.hass, options, self._genai_client ) return self.async_show_form( - step_id="init", - data_schema=vol.Schema(schema), + step_id="init", data_schema=vol.Schema(schema), errors=errors ) @@ -341,6 +345,13 @@ async def google_generative_ai_config_option_schema( }, default=RECOMMENDED_HARM_BLOCK_THRESHOLD, ): harm_block_thresholds_selector, + vol.Optional( + CONF_USE_GOOGLE_SEARCH_TOOL, + description={ + "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL), + }, + default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + ): bool, } ) return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 35834f6e7f9..108ffe1891d 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -22,5 +22,7 @@ CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold" CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold" RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE" +CONF_USE_GOOGLE_SEARCH_TOOL = "enable_google_search_tool" +RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False TIMEOUT_MILLIS = 10000 diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 4648f1afb4c..7c19c5445a7 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -4,6 +4,7 @@ from __future__ import annotations import codecs from collections.abc import Callable +from dataclasses import replace from typing import Any, Literal, cast from google.genai.errors import APIError @@ -12,6 +13,7 @@ from google.genai.types import ( Content, FunctionDeclaration, GenerateContentConfig, + GoogleSearch, HarmCategory, Part, SafetySetting, @@ -39,6 +41,7 @@ from .const import ( CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, @@ -168,17 +171,25 @@ def _escape_decode(value: Any) -> Any: return value +def _create_google_tool_response_parts( + parts: list[conversation.ToolResultContent], +) -> list[Part]: + """Create Google tool response parts.""" + return [ + Part.from_function_response( + name=tool_result.tool_name, response=tool_result.tool_result + ) + for tool_result in parts + ] + + def _create_google_tool_response_content( content: list[conversation.ToolResultContent], ) -> Content: """Create a Google tool response content.""" return Content( - parts=[ - Part.from_function_response( - name=tool_result.tool_name, response=tool_result.tool_result - ) - for tool_result in content - ] + role="user", + parts=_create_google_tool_response_parts(content), ) @@ -296,6 +307,13 @@ class GoogleGenerativeAIConversationEntity( for tool in chat_log.llm_api.tools ] + # Using search grounding allows the model to retrieve information from the web, + # however, it may interfere with how the model decides to use some tools, or entities + # for example weather entity may be disregarded if the model chooses to Google it. + if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True: + tools = tools or [] + tools.append(Tool(google_search=GoogleSearch())) + model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) # Gemini 1.0 doesn't support system_instruction while 1.5 does. # Assume future versions will support it (if not, the request fails with a @@ -324,12 +342,29 @@ class GoogleGenerativeAIConversationEntity( tool_results.append(chat_content) continue + if ( + not isinstance(chat_content, conversation.ToolResultContent) + and chat_content.content == "" + ): + # Skipping is not possible since the number of function calls need to match the number of function responses + # and skipping one would mean removing the other and hence this would prevent a proper chat log + chat_content = replace(chat_content, content=" ") + if tool_results: messages.append(_create_google_tool_response_content(tool_results)) tool_results.clear() messages.append(_convert_content(chat_content)) + # The SDK requires the first message to be a user message + # This is not the case if user used `start_conversation` + # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 + if messages and messages[0].role != "user": + messages.insert( + 0, + Content(role="user", parts=[Part.from_text(text=" ")]), + ) + if tool_results: messages.append(_create_google_tool_response_content(tool_results)) generateContentConfig = GenerateContentConfig( @@ -384,7 +419,7 @@ class GoogleGenerativeAIConversationEntity( chat = self._genai_client.aio.chats.create( model=model_name, history=messages, config=generateContentConfig ) - chat_request: str | Content = user_input.text + chat_request: str | list[Part] = user_input.text # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: @@ -403,6 +438,18 @@ class GoogleGenerativeAIConversationEntity( error = f"Sorry, I had a problem talking to Google Generative AI: {err}" raise HomeAssistantError(error) from err + if (usage_metadata := chat_response.usage_metadata) is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": usage_metadata.prompt_token_count, + "cached_input_tokens": usage_metadata.cached_content_token_count + or 0, + "output_tokens": usage_metadata.candidates_token_count, + } + } + ) + response_parts = chat_response.candidates[0].content.parts if not response_parts: raise HomeAssistantError( @@ -426,7 +473,7 @@ class GoogleGenerativeAIConversationEntity( ) ) - chat_request = _create_google_tool_response_content( + chat_request = _create_google_tool_response_parts( [ tool_response async for tool_response in chat_log.async_add_assistant_content( diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index ed215970d7f..25e44964a6d 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-genai==1.1.0"] + "requirements": ["google-genai==1.7.0"] } diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 7bf1831a34b..2697f30eda0 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -36,12 +36,17 @@ "harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes", "hate_block_threshold": "Content that is rude, disrespectful, or profane", "sexual_block_threshold": "Contains references to sexual acts or other lewd content", - "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts" + "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts", + "enable_google_search_tool": "Enable Google Search tool" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "enable_google_search_tool": "Only works with \"No control\" in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"." } } + }, + "error": { + "invalid_google_search_option": "Google Search cannot be enabled alongside any Assist capability, this can only be used when Assist is set to \"No control\"." } }, "services": { diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json index d3e57c26e39..b96f4e9ebc0 100644 --- a/homeassistant/components/google_pubsub/manifest.json +++ b/homeassistant/components/google_pubsub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_pubsub", "iot_class": "cloud_push", "quality_scale": "legacy", - "requirements": ["google-cloud-pubsub==2.28.0"] + "requirements": ["google-cloud-pubsub==2.29.0"] } diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index 45911433b92..403cf7d45fc 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -16,13 +16,13 @@ "name": "Panel light" }, "quiet": { - "name": "Quiet" + "name": "Quiet mode" }, "fresh_air": { "name": "Fresh air" }, "xfan": { - "name": "XFan" + "name": "Xtra fan" }, "health_mode": { "name": "Health mode" diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 8b745ff2b99..7a5677cb687 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -79,6 +79,7 @@ SERVICE_CREATE_HABIT = "create_habit" SERVICE_UPDATE_TODO = "update_todo" SERVICE_CREATE_TODO = "create_todo" SERVICE_UPDATE_DAILY = "update_daily" +SERVICE_CREATE_DAILY = "create_daily" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index fcb9ec56fa7..aac90814af5 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -270,6 +270,14 @@ "repeat_weekly_options": "mdi:calendar-refresh", "repeat_monthly_options": "mdi:calendar-refresh" } + }, + "create_daily": { + "service": "mdi:calendar-month", + "sections": { + "developer_options": "mdi:test-tube", + "repeat_weekly_options": "mdi:calendar-refresh", + "repeat_monthly_options": "mdi:calendar-refresh" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 9fb0b0b7537..bcbd6caa7a7 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -84,6 +84,7 @@ from .const import ( SERVICE_API_CALL, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_DAILY, SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_CREATE_TODO, @@ -243,6 +244,7 @@ SERVICE_TASK_TYPE_MAP = { SERVICE_UPDATE_TODO: TaskType.TODO, SERVICE_CREATE_TODO: TaskType.TODO, SERVICE_UPDATE_DAILY: TaskType.DAILY, + SERVICE_CREATE_DAILY: TaskType.DAILY, } @@ -913,7 +915,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) - for service in (SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_CREATE_TODO): + for service in ( + SERVICE_CREATE_DAILY, + SERVICE_CREATE_HABIT, + SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, + ): hass.services.async_register( DOMAIN, service, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 46b3211790e..3fb25e2b4b7 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -347,11 +347,11 @@ update_daily: notes: *notes checklist_options: *checklist_options priority: *priority - start_date: + start_date: &start_date required: false selector: date: - frequency: + frequency: &frequency_daily required: false selector: select: @@ -362,7 +362,7 @@ update_daily: - "yearly" translation_key: "frequency" mode: dropdown - every_x: + every_x: &every_x required: false selector: number: @@ -370,7 +370,7 @@ update_daily: step: 1 unit_of_measurement: "🔃" mode: box - repeat_weekly_options: + repeat_weekly_options: &repeat_weekly_options collapsed: true fields: repeat: @@ -388,7 +388,7 @@ update_daily: mode: list translation_key: repeat multiple: true - repeat_monthly_options: + repeat_monthly_options: &repeat_monthly_options collapsed: true fields: repeat_monthly: @@ -403,7 +403,7 @@ update_daily: reminder_options: collapsed: true fields: - reminder: + reminder: &reminder_daily required: false selector: text: @@ -420,7 +420,7 @@ update_daily: developer_options: collapsed: true fields: - streak: + streak: &streak required: false selector: number: @@ -429,3 +429,18 @@ update_daily: unit_of_measurement: "▶▶" mode: box alias: *alias +create_daily: + fields: + config_entry: *config_entry + name: *name + notes: *notes + add_checklist_item: *add_checklist_item + priority: *priority + start_date: *start_date + frequency: *frequency_daily + every_x: *every_x + repeat_weekly_options: *repeat_weekly_options + repeat_monthly_options: *repeat_monthly_options + reminder: *reminder_daily + tag: *tag + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index fac0fdf3868..695eb1576fe 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -52,7 +52,19 @@ "reminder_options_description": "Add, remove or clear reminders of a Habitica task.", "date_name": "Due date", "date_description": "The to-do's due date.", - "repeat_name": "Repeat on" + "repeat_name": "Repeat on", + "start_date_name": "Start date", + "start_date_description": "Defines when the daily task becomes active and specifies the exact weekday or day of the month it repeats on.", + "frequency_daily_name": "Repeat interval", + "frequency_daily_description": "The repetition interval of a daily.", + "every_x_name": "Repeat every X", + "every_x_description": "The number of intervals (days, weeks, months, or years) after which the daily repeats, based on the chosen repetition interval. A value of 0 makes the daily inactive ('Grey Daily').", + "repeat_weekly_description": "The days of the week the daily repeats.", + "repeat_monthly_description": "Whether a monthly recurring task repeats on the same calendar day each month or on the same weekday and week of the month, based on the start date.", + "repeat_weekly_options_name": "Weekly repeat days", + "repeat_weekly_options_description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly.", + "repeat_monthly_options_name": "Monthly repeat day", + "repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly." }, "config": { "abort": { @@ -1076,24 +1088,24 @@ "description": "[%key:component::habitica::common::priority_description%]" }, "start_date": { - "name": "Start date", - "description": "Defines when the daily task becomes active and specifies the exact weekday or day of the month it repeats on." + "name": "[%key:component::habitica::common::start_date_name%]", + "description": "[%key:component::habitica::common::start_date_description%]" }, "frequency": { - "name": "Repeat interval", - "description": "The repetition interval of a daily." + "name": "[%key:component::habitica::common::frequency_daily_name%]", + "description": "[%key:component::habitica::common::frequency_daily_description%]" }, "every_x": { - "name": "Repeat every X", - "description": "The number of intervals (days, weeks, months, or years) after which the daily repeats, based on the chosen repetition interval. A value of 0 makes the daily inactive ('Grey Daily')." + "name": "[%key:component::habitica::common::every_x_name%]", + "description": "[%key:component::habitica::common::every_x_description%]" }, "repeat": { "name": "[%key:component::habitica::common::repeat_name%]", - "description": "The days of the week the daily repeats." + "description": "[%key:component::habitica::common::repeat_weekly_description%]" }, "repeat_monthly": { "name": "[%key:component::habitica::common::repeat_name%]", - "description": "Whether a monthly recurring task repeats on the same calendar day each month or on the same weekday and week of the month, based on the start date." + "description": "[%key:component::habitica::common::repeat_monthly_description%]" }, "add_checklist_item": { "name": "[%key:component::habitica::common::add_checklist_item_name%]", @@ -1134,12 +1146,12 @@ "description": "[%key:component::habitica::common::checklist_options_description%]" }, "repeat_weekly_options": { - "name": "Weekly repeat days", - "description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly." + "name": "[%key:component::habitica::common::repeat_weekly_options_name%]", + "description": "[%key:component::habitica::common::repeat_weekly_options_description%]" }, "repeat_monthly_options": { - "name": "Monthly repeat day", - "description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly." + "name": "[%key:component::habitica::common::repeat_monthly_options_name%]", + "description": "[%key:component::habitica::common::repeat_monthly_options_description%]" }, "tag_options": { "name": "[%key:component::habitica::common::tag_options_name%]", @@ -1154,6 +1166,78 @@ "description": "[%key:component::habitica::common::reminder_options_description%]" } } + }, + "create_daily": { + "name": "Create a daily", + "description": "Adds a new daily.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::common::config_entry_description%]" + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "start_date": { + "name": "[%key:component::habitica::common::start_date_name%]", + "description": "[%key:component::habitica::common::start_date_description%]" + }, + "frequency": { + "name": "[%key:component::habitica::common::frequency_daily_name%]", + "description": "[%key:component::habitica::common::frequency_daily_description%]" + }, + "every_x": { + "name": "[%key:component::habitica::common::every_x_name%]", + "description": "[%key:component::habitica::common::every_x_description%]" + }, + "repeat": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "[%key:component::habitica::common::repeat_weekly_description%]" + }, + "repeat_monthly": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "[%key:component::habitica::common::repeat_monthly_description%]" + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::checklist_options_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_options_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + } + }, + "sections": { + "repeat_weekly_options": { + "name": "[%key:component::habitica::common::repeat_weekly_options_name%]", + "description": "[%key:component::habitica::common::repeat_weekly_options_description%]" + }, + "repeat_monthly_options": { + "name": "[%key:component::habitica::common::repeat_monthly_options_name%]", + "description": "[%key:component::habitica::common::repeat_monthly_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index a543dbc7f89..68a747eb16d 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -24,8 +24,8 @@ "fix_menu": { "description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.", "menu_options": { - "addon_execute_start": "Start", - "addon_disable_boot": "Disable" + "addon_execute_start": "[%key:common::action::start%]", + "addon_disable_boot": "[%key:common::action::disable%]" } } }, @@ -265,6 +265,11 @@ "version_latest": { "name": "Newest version" } + }, + "update": { + "update": { + "name": "[%key:component::update::title%]" + } } }, "services": { diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 4ea703e87c3..263cf2dfe13 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -39,7 +39,7 @@ from .entity import ( from .update_helper import update_addon, update_core ENTITY_DESCRIPTION = UpdateEntityDescription( - name="Update", + translation_key="update", key=ATTR_VERSION_LATEST, ) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 6d603f7ad30..d49fc17aa53 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -2,10 +2,15 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" +ATTR_DESTINATION_POSITION = "destination_position" +ATTR_QUEUE_IDS = "queue_ids" DOMAIN = "heos" ENTRY_TITLE = "HEOS System" +SERVICE_GET_QUEUE = "get_queue" SERVICE_GROUP_VOLUME_SET = "group_volume_set" SERVICE_GROUP_VOLUME_DOWN = "group_volume_down" SERVICE_GROUP_VOLUME_UP = "group_volume_up" +SERVICE_MOVE_QUEUE_ITEM = "move_queue_item" +SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 0333c60ec21..5e72eb1427e 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -6,7 +6,6 @@ entities to update. Entities subscribe to entity-specific updates within the ent """ from collections.abc import Callable, Sequence -from datetime import datetime, timedelta import logging from typing import Any @@ -25,10 +24,10 @@ from pyheos import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -60,7 +59,13 @@ class HeosCoordinator(DataUpdateCoordinator[None]): ) ) self._platform_callbacks: list[Callable[[Sequence[HeosPlayer]], None]] = [] - self._update_sources_pending: bool = False + self._update_sources_debouncer = Debouncer( + hass, + _LOGGER, + immediate=True, + cooldown=2.0, + function=self._async_update_sources, + ) self._source_list: list[str] = [] self._favorites: dict[int, MediaItem] = {} self._inputs: Sequence[MediaItem] = [] @@ -182,31 +187,9 @@ class HeosCoordinator(DataUpdateCoordinator[None]): if event == const.EVENT_PLAYERS_CHANGED: assert data is not None self._async_handle_player_update_result(data) - elif ( - event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) - and not self._update_sources_pending - ): - # Update the sources after a brief delay as we may have received multiple qualifying - # events at once and devices cannot handle immediately attempting to refresh sources. - self._update_sources_pending = True - - async def update_sources_job(_: datetime | None = None) -> None: - await self._async_update_sources() - self._update_sources_pending = False - self.async_update_listeners() - - assert self.config_entry is not None - self.config_entry.async_on_unload( - async_call_later( - self.hass, - timedelta(seconds=1), - HassJob( - update_sources_job, - "heos_update_sources", - cancel_on_shutdown=True, - ), - ) - ) + elif event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED): + # Debounce because we may have received multiple qualifying events in rapid succession. + await self._update_sources_debouncer.async_call() self.async_update_listeners() def _async_update_player_ids(self, updated_player_ids: dict[int, int]) -> None: @@ -268,15 +251,6 @@ class HeosCoordinator(DataUpdateCoordinator[None]): else: self._source_list.extend([source.name for source in self._inputs]) - async def _async_update_players(self) -> None: - """Update players after reconnection.""" - try: - player_updates = await self.heos.load_players() - except HeosError as error: - _LOGGER.error("Unable to refresh players: %s", error) - return - self._async_handle_player_update_result(player_updates) - @callback def async_get_source_list(self) -> list[str]: """Return the list of sources for players.""" diff --git a/homeassistant/components/heos/icons.json b/homeassistant/components/heos/icons.json index d7a998b6aec..b03f15a4b0f 100644 --- a/homeassistant/components/heos/icons.json +++ b/homeassistant/components/heos/icons.json @@ -1,5 +1,14 @@ { "services": { + "get_queue": { + "service": "mdi:playlist-music" + }, + "remove_from_queue": { + "service": "mdi:playlist-remove" + }, + "move_queue_item": { + "service": "mdi:playlist-edit" + }, "group_volume_set": { "service": "mdi:volume-medium" }, diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 19feffd8ef1..cbac9f20574 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "platinum", - "requirements": ["pyheos==1.0.3"], + "requirements": ["pyheos==1.0.4"], "ssdp": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 5c0a66a02fa..294da492e31 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine, Sequence from contextlib import suppress +import dataclasses from datetime import datetime from functools import reduce, wraps import logging @@ -23,12 +24,10 @@ from pyheos import ( const as heos_const, ) from pyheos.util import mediauri as heos_source -import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, - ATTR_MEDIA_VOLUME_LEVEL, BrowseError, BrowseMedia, MediaClass, @@ -42,24 +41,16 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.media_source import BrowseMediaSource from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - entity_registry as er, -) +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from .const import ( - DOMAIN as HEOS_DOMAIN, - SERVICE_GROUP_VOLUME_DOWN, - SERVICE_GROUP_VOLUME_SET, - SERVICE_GROUP_VOLUME_UP, -) +from . import services +from .const import DOMAIN as HEOS_DOMAIN from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 @@ -130,19 +121,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add media players for a config entry.""" - # Register custom entity services - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_SET, - {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float}, - "async_set_group_volume_level", - ) - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_DOWN, None, "async_group_volume_down" - ) - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_UP, None, "async_group_volume_up" - ) + services.register_media_player_services() def add_entities_callback(players: Sequence[HeosPlayer]) -> None: """Add entities for each player.""" @@ -155,20 +134,20 @@ async def async_setup_entry( add_entities_callback(list(coordinator.heos.players.values())) -type _FuncType[**_P] = Callable[_P, Awaitable[Any]] -type _ReturnFuncType[**_P] = Callable[_P, Coroutine[Any, Any, None]] +type _FuncType[**_P, _R] = Callable[_P, Awaitable[_R]] +type _ReturnFuncType[**_P, _R] = Callable[_P, Coroutine[Any, Any, _R]] -def catch_action_error[**_P]( +def catch_action_error[**_P, _R]( action: str, -) -> Callable[[_FuncType[_P]], _ReturnFuncType[_P]]: +) -> Callable[[_FuncType[_P, _R]], _ReturnFuncType[_P, _R]]: """Return decorator that catches errors and raises HomeAssistantError.""" - def decorator(func: _FuncType[_P]) -> _ReturnFuncType[_P]: + def decorator(func: _FuncType[_P, _R]) -> _ReturnFuncType[_P, _R]: @wraps(func) - async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None: + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: try: - await func(*args, **kwargs) + return await func(*args, **kwargs) except (HeosError, ValueError) as ex: raise HomeAssistantError( translation_domain=HEOS_DOMAIN, @@ -268,6 +247,12 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): self.async_on_remove(self._player.add_on_player_event(self._player_update)) await super().async_added_to_hass() + @catch_action_error("get queue") + async def async_get_queue(self) -> ServiceResponse: + """Get the queue for the current player.""" + queue = await self._player.get_queue() + return {"queue": [dataclasses.asdict(item) for item in queue]} + @catch_action_error("clear playlist") async def async_clear_playlist(self) -> None: """Clear players playlist.""" @@ -368,6 +353,15 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): await self._player.play_preset_station(index) return + if media_type == "queue": + # media_id must be an int + try: + queue_id = int(media_id) + except ValueError: + raise ValueError(f"Invalid queue id '{media_id}'") from None + await self._player.play_queue(queue_id) + return + raise ValueError(f"Unsupported media type '{media_type}'") @catch_action_error("select source") @@ -481,6 +475,17 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): await self.coordinator.heos.set_group(new_members) return + async def async_remove_from_queue(self, queue_ids: list[int]) -> None: + """Remove items from the queue.""" + await self._player.remove_from_queue(queue_ids) + + @catch_action_error("move queue item") + async def async_move_queue_item( + self, queue_ids: list[int], destination_position: int + ) -> None: + """Move items in the queue.""" + await self._player.move_queue_item(queue_ids, destination_position) + @property def available(self) -> bool: """Return True if the device is available.""" @@ -580,7 +585,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): children: list[BrowseMedia] = [ _media_to_browse_media(source) for source in self.coordinator.heos.music_sources.values() - if source.available + if source.available or source.source_id == heos_const.MUSIC_SOURCE_TUNEIN ] root = BrowseMedia( title="Music Sources", @@ -654,7 +659,9 @@ def _media_to_browse_media(media: MediaItem | MediaMusicSource) -> BrowseMedia: can_play = False if isinstance(media, MediaMusicSource): - can_expand = media.available + can_expand = ( + media.source_id == heos_const.MUSIC_SOURCE_TUNEIN or media.available + ) else: can_expand = media.browsable can_play = media.playable diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index dc11bb7a76d..86c6f6d0533 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -1,19 +1,35 @@ """Services for the HEOS integration.""" +from dataclasses import dataclass import logging +from typing import Final from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol +from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + issue_registry as ir, +) +from homeassistant.helpers.typing import VolDictType, VolSchemaType from .const import ( + ATTR_DESTINATION_POSITION, ATTR_PASSWORD, + ATTR_QUEUE_IDS, ATTR_USERNAME, DOMAIN, + SERVICE_GET_QUEUE, + SERVICE_GROUP_VOLUME_DOWN, + SERVICE_GROUP_VOLUME_SET, + SERVICE_GROUP_VOLUME_UP, + SERVICE_MOVE_QUEUE_ITEM, + SERVICE_REMOVE_FROM_QUEUE, SERVICE_SIGN_IN, SERVICE_SIGN_OUT, ) @@ -44,6 +60,75 @@ def register(hass: HomeAssistant) -> None: ) +@dataclass(frozen=True) +class EntityServiceDescription: + """Describe an entity service.""" + + name: str + method_name: str + schema: VolDictType | VolSchemaType | None = None + supports_response: SupportsResponse = SupportsResponse.NONE + + def async_register(self, platform: entity_platform.EntityPlatform) -> None: + """Register the service with the platform.""" + platform.async_register_entity_service( + self.name, + self.schema, + self.method_name, + supports_response=self.supports_response, + ) + + +REMOVE_FROM_QUEUE_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_QUEUE_IDS): vol.All( + cv.ensure_list, + [vol.All(cv.positive_int, vol.Range(min=1))], + vol.Unique(), + ) +} +GROUP_VOLUME_SET_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float +} +MOVE_QEUEUE_ITEM_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_QUEUE_IDS): vol.All( + cv.ensure_list, + [vol.All(vol.Coerce(int), vol.Range(min=1, max=1000))], + vol.Unique(), + ), + vol.Required(ATTR_DESTINATION_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=1, max=1000) + ), +} + +MEDIA_PLAYER_ENTITY_SERVICES: Final = ( + # Player queue services + EntityServiceDescription( + SERVICE_GET_QUEUE, "async_get_queue", supports_response=SupportsResponse.ONLY + ), + EntityServiceDescription( + SERVICE_REMOVE_FROM_QUEUE, "async_remove_from_queue", REMOVE_FROM_QUEUE_SCHEMA + ), + EntityServiceDescription( + SERVICE_MOVE_QUEUE_ITEM, "async_move_queue_item", MOVE_QEUEUE_ITEM_SCHEMA + ), + # Group volume services + EntityServiceDescription( + SERVICE_GROUP_VOLUME_SET, + "async_set_group_volume_level", + GROUP_VOLUME_SET_SCHEMA, + ), + EntityServiceDescription(SERVICE_GROUP_VOLUME_DOWN, "async_group_volume_down"), + EntityServiceDescription(SERVICE_GROUP_VOLUME_UP, "async_group_volume_up"), +) + + +def register_media_player_services() -> None: + """Register media_player entity services.""" + platform = entity_platform.async_get_current_platform() + for service in MEDIA_PLAYER_ENTITY_SERVICES: + service.async_register(platform) + + def _get_controller(hass: HomeAssistant) -> Heos: """Get the HEOS controller instance.""" _LOGGER.warning( diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index 8f3a43421f6..333a15940bc 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -1,3 +1,42 @@ +get_queue: + target: + entity: + integration: heos + domain: media_player + +remove_from_queue: + target: + entity: + integration: heos + domain: media_player + fields: + queue_ids: + required: true + selector: + text: + multiple: true + type: number + +move_queue_item: + target: + entity: + integration: heos + domain: media_player + fields: + queue_ids: + required: true + selector: + text: + multiple: true + type: number + destination_position: + required: true + selector: + number: + min: 1 + max: 1000 + step: 1 + group_volume_set: target: entity: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 593c437accc..c99d73a70d7 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -86,6 +86,34 @@ } } }, + "get_queue": { + "name": "Get queue", + "description": "Retrieves the queue of the media player." + }, + "remove_from_queue": { + "name": "Remove from queue", + "description": "Removes items from the play queue.", + "fields": { + "queue_ids": { + "name": "Queue IDs", + "description": "The IDs (indexes) of the items in the queue to remove." + } + } + }, + "move_queue_item": { + "name": "Move queue item", + "description": "Move one or more items within the play queue.", + "fields": { + "queue_ids": { + "name": "Queue IDs", + "description": "The IDs (indexes) of the items in the queue to move." + }, + "destination_position": { + "name": "Destination position", + "description": "The position index in the queue to move the items to." + } + } + }, "group_volume_down": { "name": "Turn down group volume", "description": "Turns down the group volume." diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 6323a2eecbf..5fa15b68d1a 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -34,9 +34,9 @@ } }, "error": { - "invalid_username": "Failed to sign into Hive. Your email address is not recognised.", - "invalid_password": "Failed to sign into Hive. Incorrect password, please try again.", - "invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.", + "invalid_username": "Failed to sign in to Hive. Your email address is not recognised.", + "invalid_password": "Failed to sign in to Hive. Incorrect password, please try again.", + "invalid_code": "Failed to sign in to Hive. Your two-factor authentication code was incorrect.", "no_internet_available": "An Internet connection is required to connect to Hive.", "unknown": "[%key:common::config_flow::error::unknown%]" }, diff --git a/homeassistant/components/hko/config_flow.py b/homeassistant/components/hko/config_flow.py index 8548bb4767d..1e2a6230455 100644 --- a/homeassistant/components/hko/config_flow.py +++ b/homeassistant/components/hko/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from asyncio import timeout +import logging from typing import Any from hko import HKO, LOCATIONS, HKOError @@ -15,6 +16,8 @@ from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .const import API_RHRREAD, DEFAULT_LOCATION, DOMAIN, KEY_LOCATION +_LOGGER = logging.getLogger(__name__) + def get_loc_name(item): """Return an array of supported locations.""" @@ -54,7 +57,8 @@ class HKOConfigFlow(ConfigFlow, domain=DOMAIN): except HKOError: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: await self.async_set_unique_id( diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index ec47b222370..4c73210c36e 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.68", "babel==2.15.0"] + "requirements": ["holidays==0.69", "babel==2.15.0"] } diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json index d464f9e8bfd..6e317b8fa7b 100644 --- a/homeassistant/components/holiday/strings.json +++ b/homeassistant/components/holiday/strings.json @@ -8,7 +8,7 @@ "step": { "user": { "data": { - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" } }, "options": { diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 70b357518da..fe01a3e9564 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,195 +2,29 @@ from __future__ import annotations -from collections.abc import Awaitable import logging -from typing import Any, cast +from typing import Any from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import ( - ArrayOfOptions, - CommandKey, - Option, - OptionKey, - ProgramKey, - SettingKey, -) -from aiohomeconnect.model.error import HomeConnectError import aiohttp -import voluptuous as vol -from homeassistant.const import ATTR_DEVICE_ID, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) -from homeassistant.helpers import ( - config_entry_oauth2_flow, - config_validation as cv, - device_registry as dr, -) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) +from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth -from .const import ( - AFFECTS_TO_ACTIVE_PROGRAM, - AFFECTS_TO_SELECTED_PROGRAM, - ATTR_AFFECTS_TO, - ATTR_KEY, - ATTR_PROGRAM, - ATTR_UNIT, - ATTR_VALUE, - DOMAIN, - OLD_NEW_UNIQUE_ID_SUFFIX_MAP, - PROGRAM_ENUM_OPTIONS, - SERVICE_OPTION_ACTIVE, - SERVICE_OPTION_SELECTED, - SERVICE_PAUSE_PROGRAM, - SERVICE_RESUME_PROGRAM, - SERVICE_SELECT_PROGRAM, - SERVICE_SET_PROGRAM_AND_OPTIONS, - SERVICE_SETTING, - SERVICE_START_PROGRAM, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, - SVE_TRANSLATION_PLACEHOLDER_VALUE, - TRANSLATION_KEYS_PROGRAMS_MAP, -) +from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator -from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +from .services import register_actions _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - -PROGRAM_OPTIONS = { - bsh_key_to_translation_key(key): ( - key, - value, - ) - for key, value in { - OptionKey.BSH_COMMON_DURATION: int, - OptionKey.BSH_COMMON_START_IN_RELATIVE: int, - OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, - OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, - OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, - OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool, - OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool, - OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, - OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, - OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, - OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, - }.items() -} - - -SERVICE_SETTING_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(SettingKey), - vol.NotIn([SettingKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(str, int, bool), - } -) - -# DEPRECATED: Remove in 2025.9.0 -SERVICE_OPTION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(str, int, bool), - vol.Optional(ATTR_UNIT): str, - } -) - -# DEPRECATED: Remove in 2025.9.0 -SERVICE_PROGRAM_SCHEMA = vol.Any( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(int, str), - vol.Optional(ATTR_UNIT): str, - }, - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - }, -) - - -def _require_program_or_at_least_one_option(data: dict) -> dict: - if ATTR_PROGRAM not in data and not any( - option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS) - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="required_program_or_one_option_at_least", - ) - return data - - -SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_AFFECTS_TO): vol.In( - [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM] - ), - vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()), - } - ) - .extend( - { - vol.Optional(translation_key): vol.In(allowed_values.keys()) - for translation_key, ( - key, - allowed_values, - ) in PROGRAM_ENUM_OPTIONS.items() - } - ) - .extend( - { - vol.Optional(translation_key): schema - for translation_key, (key, schema) in PROGRAM_OPTIONS.items() - } - ), - _require_program_or_at_least_one_option, -) - -SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -203,406 +37,9 @@ PLATFORMS = [ ] -async def _get_client_and_ha_id( - hass: HomeAssistant, device_id: str -) -> tuple[HomeConnectClient, str]: - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) - if device_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="device_entry_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - entry: HomeConnectConfigEntry | None = None - for entry_id in device_entry.config_entries: - _entry = hass.config_entries.async_get_entry(entry_id) - assert _entry - if _entry.domain == DOMAIN: - entry = cast(HomeConnectConfigEntry, _entry) - break - if entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="config_entry_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - - ha_id = next( - ( - identifier[1] - for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - ), - None, - ) - if ha_id is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="appliance_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - return entry.runtime_data.client, ha_id - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - - async def _async_service_program(call: ServiceCall, start: bool) -> None: - """Execute calls to services taking a program.""" - program = call.data[ATTR_PROGRAM] - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - option_key = call.data.get(ATTR_KEY) - options = ( - [ - Option( - option_key, - call.data[ATTR_VALUE], - unit=call.data.get(ATTR_UNIT), - ) - ] - if option_key is not None - else None - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_PROGRAM}: {program}", - *([f" {ATTR_KEY}: {options[0].key}"] if options else []), - *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), - *( - [f" {ATTR_UNIT}: {options[0].unit}"] - if options and options[0].unit - else [] - ), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", - f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", - *( - [ - f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" - ] - if options - else [] - ), - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - - try: - if start: - await client.start_program(ha_id, program_key=program, options=options) - else: - await client.set_selected_program( - ha_id, program_key=program, options=options - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="start_program" if start else "select_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, - }, - ) from err - - async def _async_service_set_program_options( - call: ServiceCall, active: bool - ) -> None: - """Execute calls to services taking a program.""" - option_key = call.data[ATTR_KEY] - value = call.data[ATTR_VALUE] - unit = call.data.get(ATTR_UNIT) - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - async_create_issue( - hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_KEY}: {option_key}", - f" {ATTR_VALUE}: {value}", - *([f" {ATTR_UNIT}: {unit}"] if unit else []), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", - f" {bsh_key_to_translation_key(option_key)}: {value}", - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - try: - if active: - await client.set_active_program_option( - ha_id, - option_key=option_key, - value=value, - unit=unit, - ) - else: - await client.set_selected_program_option( - ha_id, - option_key=option_key, - value=value, - unit=unit, - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_options_active_program" - if active - else "set_options_selected_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_KEY: option_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), - }, - ) from err - - async def _async_service_command( - call: ServiceCall, command_key: CommandKey - ) -> None: - """Execute calls to services executing a command.""" - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - async_create_issue( - hass, - DOMAIN, - "deprecated_command_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_command_actions", - ) - - try: - await client.put_command(ha_id, command_key=command_key, value=True) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="execute_command", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "command": command_key.value, - }, - ) from err - - async def async_service_option_active(call: ServiceCall) -> None: - """Service for setting an option for an active program.""" - await _async_service_set_program_options(call, True) - - async def async_service_option_selected(call: ServiceCall) -> None: - """Service for setting an option for a selected program.""" - await _async_service_set_program_options(call, False) - - async def async_service_setting(call: ServiceCall) -> None: - """Service for changing a setting.""" - key = call.data[ATTR_KEY] - value = call.data[ATTR_VALUE] - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - try: - await client.set_setting(ha_id, setting_key=key, value=value) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_setting", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_KEY: key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), - }, - ) from err - - async def async_service_pause_program(call: ServiceCall) -> None: - """Service for pausing a program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - - async def async_service_resume_program(call: ServiceCall) -> None: - """Service for resuming a paused program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - - async def async_service_select_program(call: ServiceCall) -> None: - """Service for selecting a program.""" - await _async_service_program(call, False) - - async def async_service_set_program_and_options(call: ServiceCall) -> None: - """Service for setting a program and options.""" - data = dict(call.data) - program = data.pop(ATTR_PROGRAM, None) - affects_to = data.pop(ATTR_AFFECTS_TO) - client, ha_id = await _get_client_and_ha_id(hass, data.pop(ATTR_DEVICE_ID)) - - options: list[Option] = [] - - for option, value in data.items(): - if option in PROGRAM_ENUM_OPTIONS: - options.append( - Option( - PROGRAM_ENUM_OPTIONS[option][0], - PROGRAM_ENUM_OPTIONS[option][1][value], - ) - ) - elif option in PROGRAM_OPTIONS: - option_key = PROGRAM_OPTIONS[option][0] - options.append(Option(option_key, value)) - - method_call: Awaitable[Any] - exception_translation_key: str - if program: - program = ( - program - if isinstance(program, ProgramKey) - else TRANSLATION_KEYS_PROGRAMS_MAP[program] - ) - - if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: - method_call = client.start_program( - ha_id, program_key=program, options=options - ) - exception_translation_key = "start_program" - elif affects_to == AFFECTS_TO_SELECTED_PROGRAM: - method_call = client.set_selected_program( - ha_id, program_key=program, options=options - ) - exception_translation_key = "select_program" - else: - array_of_options = ArrayOfOptions(options) - if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: - method_call = client.set_active_program_options( - ha_id, array_of_options=array_of_options - ) - exception_translation_key = "set_options_active_program" - else: - # affects_to is AFFECTS_TO_SELECTED_PROGRAM - method_call = client.set_selected_program_options( - ha_id, array_of_options=array_of_options - ) - exception_translation_key = "set_options_selected_program" - - try: - await method_call - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=exception_translation_key, - translation_placeholders={ - **get_dict_from_home_connect_error(err), - **( - {SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program} - if program - else {} - ), - }, - ) from err - - async def async_service_start_program(call: ServiceCall) -> None: - """Service for starting a program.""" - await _async_service_program(call, True) - - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_ACTIVE, - async_service_option_active, - schema=SERVICE_OPTION_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_SELECTED, - async_service_option_selected, - schema=SERVICE_OPTION_SCHEMA, - ) - hass.services.async_register( - DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA - ) - hass.services.async_register( - DOMAIN, - SERVICE_PAUSE_PROGRAM, - async_service_pause_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_PROGRAM, - async_service_resume_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SELECT_PROGRAM, - async_service_select_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_START_PROGRAM, - async_service_start_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SET_PROGRAM_AND_OPTIONS, - async_service_set_program_and_options, - schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, - ) - + register_actions(hass) return True diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 1f82aa71766..7e4523201f9 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -5,37 +5,18 @@ from typing import cast from aiohomeconnect.model import EventKey, StatusKey -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from .common import setup_home_connect_entry -from .const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, - DOMAIN, - REFRIGERATION_STATUS_DOOR_CLOSED, - REFRIGERATION_STATUS_DOOR_OPEN, -) -from .coordinator import ( - HomeConnectApplianceData, - HomeConnectConfigEntry, - HomeConnectCoordinator, -) +from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity PARALLEL_UPDATES = 0 @@ -106,8 +87,26 @@ BINARY_SENSORS = ( key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, + translation_key="common_chiller_door", + ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, translation_key="chiller_door", ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_LEFT, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, + translation_key="left_chiller_door", + ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_RIGHT, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, + translation_key="right_chiller_door", + ), HomeConnectBinarySensorEntityDescription( key=StatusKey.REFRIGERATION_COMMON_DOOR_FLEX_COMPARTMENT, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, @@ -155,8 +154,6 @@ def _get_entities_for_appliance( for description in BINARY_SENSORS if description.key in appliance.status ) - if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: - entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) return entities @@ -202,81 +199,3 @@ class HomeConnectConnectivityBinarySensor(HomeConnectEntity, BinarySensorEntity) def available(self) -> bool: """Return the availability.""" return self.coordinator.last_update_success - - -class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): - """Binary sensor for Home Connect Generic Door.""" - - _attr_has_entity_name = False - - def __init__( - self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, - ) -> None: - """Initialize the entity.""" - super().__init__( - coordinator, - appliance, - HomeConnectBinarySensorEntityDescription( - key=StatusKey.BSH_COMMON_DOOR_STATE, - device_class=BinarySensorDeviceClass.DOOR, - boolean_map={ - BSH_DOOR_STATE_CLOSED: False, - BSH_DOOR_STATE_LOCKED: False, - BSH_DOOR_STATE_OPEN: True, - }, - ), - ) - self._attr_unique_id = f"{appliance.info.ha_id}-Door" - self._attr_name = f"{appliance.info.name} Door" - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - items = automations + scripts - if not items: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - entity_automations = [ - automation_entity - for automation_id in automations - if (automation_entity := entity_reg.async_get(automation_id)) - ] - entity_scripts = [ - script_entity - for script_id in scripts - if (script_entity := entity_reg.async_get(script_id)) - ] - - items_list = [ - f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" - for item in entity_automations - ] + [ - f"- [{item.original_name}](/config/script/edit/{item.unique_id})" - for item in entity_scripts - ] - - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_common_door_sensor_{self.entity_id}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_binary_common_door_sensor", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" - ) diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py index 726ca8cf670..0bd31c6b7c9 100644 --- a/homeassistant/components/home_connect/button.py +++ b/homeassistant/components/home_connect/button.py @@ -1,6 +1,6 @@ """Provides button entities for Home Connect.""" -from aiohomeconnect.model import CommandKey, EventKey +from aiohomeconnect.model import CommandKey from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -94,15 +94,9 @@ class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity): super().__init__( coordinator, appliance, - # The entity is subscribed to the appliance connected event, - # but it will receive also the disconnected event - ButtonEntityDescription( - key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, - ), + desc, + (appliance.info.ha_id,), ) - self.entity_description = desc - self.appliance = appliance - self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" def update_native_value(self) -> None: """Set the value of the entity.""" diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 6255a513e39..64bf4af29a4 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -79,13 +79,6 @@ ATTR_VALUE = "value" AFFECTS_TO_ACTIVE_PROGRAM = "active_program" AFFECTS_TO_SELECTED_PROGRAM = "selected_program" -SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" -SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" -SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id" -SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program" -SVE_TRANSLATION_PLACEHOLDER_KEY = "key" -SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" - TRANSLATION_KEYS_PROGRAMS_MAP = { bsh_key_to_translation_key(program.value): cast(ProgramKey, program) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 495b4efab32..fb86bb2edc6 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations from asyncio import sleep as asyncio_sleep from collections import defaultdict from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass import logging from typing import Any, cast @@ -73,6 +74,19 @@ class HomeConnectApplianceData: self.settings.update(other.settings) self.status.update(other.status) + @classmethod + def empty(cls, appliance: HomeAppliance) -> HomeConnectApplianceData: + """Return empty data.""" + return cls( + commands=set(), + events={}, + info=appliance, + options={}, + programs=[], + settings={}, + status={}, + ) + class HomeConnectCoordinator( DataUpdateCoordinator[dict[str, HomeConnectApplianceData]] @@ -119,8 +133,11 @@ class HomeConnectCoordinator( self.__dict__.pop("context_listeners", None) def remove_listener_and_invalidate_context_listeners() -> None: - remove_listener() - self.__dict__.pop("context_listeners", None) + # There are cases where the remove_listener will be called + # although it has been already removed somewhere else + with suppress(KeyError): + remove_listener() + self.__dict__.pop("context_listeners", None) return remove_listener_and_invalidate_context_listeners @@ -155,7 +172,7 @@ class HomeConnectCoordinator( f"home_connect-events_listener_task-{self.config_entry.entry_id}", ) - async def _event_listener(self) -> None: # noqa: C901 + async def _event_listener(self) -> None: """Match event with listener for event type.""" retry_time = 10 while True: @@ -279,13 +296,6 @@ class HomeConnectCoordinator( ) break - # Trigger to delete the possible depaired device entities - # from known_entities variable at common.py - for listener, context in self._special_listeners.values(): - assert isinstance(context, tuple) - if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: - listener() - @callback def _call_event_listener(self, event_message: EventMessage) -> None: """Call listener for event.""" @@ -365,15 +375,7 @@ class HomeConnectCoordinator( model=appliance.vib, ) if appliance.ha_id not in self.data: - self.data[appliance.ha_id] = HomeConnectApplianceData( - commands=set(), - events={}, - info=appliance, - options={}, - programs=[], - settings={}, - status={}, - ) + self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance) else: self.data[appliance.ha_id].info.connected = appliance.connected old_appliances.remove(appliance.ha_id) @@ -389,6 +391,13 @@ class HomeConnectCoordinator( remove_config_entry_id=self.config_entry.entry_id, ) + # Trigger to delete the possible depaired device entities + # from known_entities variable at common.py + for listener, context in self._special_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: + listener() + async def _get_appliance_data( self, appliance: HomeAppliance, @@ -402,6 +411,15 @@ class HomeConnectCoordinator( name=appliance.name, model=appliance.vib, ) + if not appliance.connected: + _LOGGER.debug( + "Appliance %s is not connected, skipping data fetch", + appliance.ha_id, + ) + if appliance_data_to_update: + appliance_data_to_update.info.connected = False + return appliance_data_to_update + return HomeConnectApplianceData.empty(appliance) try: settings = { setting.key: setting diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 8a0f9bd7640..facb3b14a9b 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -40,9 +40,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): coordinator: HomeConnectCoordinator, appliance: HomeConnectApplianceData, desc: EntityDescription, + context_override: Any | None = None, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key))) + context = (appliance.info.ha_id, EventKey(desc.key)) + if context_override is not None: + context = context_override + super().__init__(coordinator, context) self.appliance = appliance self.entity_description = desc self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index f781db3ab24..9b4c9276998 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -113,7 +113,7 @@ "milk_counter": { "default": "mdi:cup" }, - "coffee_and_milk": { + "coffee_and_milk_counter": { "default": "mdi:coffee" }, "ristretto_espresso_counter": { diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 72c6b9aaa2b..de55a60bd43 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -21,11 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .common import setup_home_connect_entry -from .const import ( - BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, -) +from .const import BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, @@ -164,7 +160,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="turn_on_light", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err if self._color_key and self._custom_color_key: @@ -183,7 +179,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="select_light_custom_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err @@ -201,7 +197,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err return @@ -211,11 +207,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): brightness = round( color_util.brightness_to_value( self._brightness_scale, - kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness), + cast(int, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness)), ) ) - hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) + hs_color = cast( + tuple[float, float], kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) + ) rgb = color_util.color_hsv_to_RGB(hs_color[0], hs_color[1], brightness) hex_val = color_util.color_rgb_to_hex(*rgb) @@ -231,7 +229,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err return @@ -254,7 +252,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_brightness", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err @@ -272,7 +270,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="turn_off_light", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 62892e7c85b..c5e277c4974 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.16.3"], + "requirements": ["aiohomeconnect==0.17.0"], "single_config_entry": true } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index db0258f2739..1bb793f4015 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -1,4 +1,4 @@ -"""Provides number enties for Home Connect.""" +"""Provides number entities for Home Connect.""" import logging from typing import cast @@ -16,14 +16,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry -from .const import ( - DOMAIN, - SVE_TRANSLATION_KEY_SET_SETTING, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, - UNIT_MAP, -) +from .const import DOMAIN, UNIT_MAP from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import get_dict_from_home_connect_error @@ -33,6 +26,11 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 NUMBERS = ( + NumberEntityDescription( + key=SettingKey.BSH_COMMON_ALARM_CLOCK, + device_class=NumberDeviceClass.DURATION, + translation_key="alarm_clock", + ), NumberEntityDescription( key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, device_class=NumberDeviceClass.TEMPERATURE, @@ -180,12 +178,12 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": str(value), }, ) from err diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 001c2e9ec31..c82e0686cb5 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -31,11 +31,6 @@ from .const import ( INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, SPIN_SPEED_OPTIONS, - SVE_TRANSLATION_KEY_SET_SETTING, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, - SVE_TRANSLATION_PLACEHOLDER_VALUE, TEMPERATURE_OPTIONS, TRANSLATION_KEYS_PROGRAMS_MAP, VARIO_PERFECT_OPTIONS, @@ -406,7 +401,7 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): translation_key=self.entity_description.error_translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, + "program": program_key.value, }, ) from err @@ -443,12 +438,12 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: value, + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": value, }, ) from err diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 796af8260fc..0f0161971a2 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,7 +1,10 @@ """Provides a sensor for Home Connect.""" +from collections import defaultdict +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta +from functools import partial import logging from typing import cast @@ -14,7 +17,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify @@ -42,7 +45,6 @@ class HomeConnectSensorEntityDescription( ): """Entity Description class for sensors.""" - default_value: str | None = None appliance_types: tuple[str, ...] | None = None fetch_unit: bool = False @@ -53,7 +55,7 @@ BSH_PROGRAM_SENSORS = ( device_class=SensorDeviceClass.TIMESTAMP, translation_key="program_finish_time", appliance_types=( - "CoffeMaker", + "CoffeeMaker", "CookProcessor", "Dishwasher", "Dryer", @@ -195,58 +197,59 @@ SENSORS = ( EVENT_SENSORS = ( HomeConnectSensorEntityDescription( - key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + key=EventKey.BSH_COMMON_EVENT_PROGRAM_ABORTED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="freezer_door_alarm", - appliance_types=("FridgeFreezer", "Freezer"), + translation_key="program_aborted", + appliance_types=("Dishwasher", "CleaningRobot", "CookProcessor"), ), HomeConnectSensorEntityDescription( - key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, + key=EventKey.BSH_COMMON_EVENT_PROGRAM_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="refrigerator_door_alarm", - appliance_types=("FridgeFreezer", "Refrigerator"), + translation_key="program_finished", + appliance_types=( + "Oven", + "Dishwasher", + "Washer", + "Dryer", + "WasherDryer", + "CleaningRobot", + "CookProcessor", + ), ), HomeConnectSensorEntityDescription( - key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, + key=EventKey.BSH_COMMON_EVENT_ALARM_CLOCK_ELAPSED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="freezer_temperature_alarm", - appliance_types=("FridgeFreezer", "Freezer"), + translation_key="alarm_clock_elapsed", + appliance_types=("Oven", "Cooktop"), ), HomeConnectSensorEntityDescription( - key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + key=EventKey.COOKING_OVEN_EVENT_PREHEAT_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="bean_container_empty", - appliance_types=("CoffeeMaker",), + translation_key="preheat_finished", + appliance_types=("Oven", "Cooktop"), ), HomeConnectSensorEntityDescription( - key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, + key=EventKey.COOKING_OVEN_EVENT_REGULAR_PREHEAT_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="water_tank_empty", - appliance_types=("CoffeeMaker",), + translation_key="regular_preheat_finished", + appliance_types=("Oven",), ), HomeConnectSensorEntityDescription( - key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, + key=EventKey.LAUNDRY_CARE_DRYER_EVENT_DRYING_PROCESS_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="drip_tray_full", - appliance_types=("CoffeeMaker",), + translation_key="drying_process_finished", + appliance_types=("Dryer",), ), HomeConnectSensorEntityDescription( key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="salt_nearly_empty", appliance_types=("Dishwasher",), ), @@ -254,10 +257,219 @@ EVENT_SENSORS = ( key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="rinse_aid_nearly_empty", appliance_types=("Dishwasher",), ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="bean_container_empty", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="water_tank_empty", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="drip_tray_full", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_KEEP_MILK_TANK_COOL, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="keep_milk_tank_cool", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_20_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="descaling_in_20_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_15_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="descaling_in_15_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_10_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="descaling_in_10_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_5_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="descaling_in_5_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_DESCALED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_should_be_descaled", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_OVERDUE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_descaling_overdue", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_BLOCKAGE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_descaling_blockage", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CLEANED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_should_be_cleaned", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CLEANING_OVERDUE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_cleaning_overdue", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN20CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="calc_n_clean_in20cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN15CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="calc_n_clean_in15cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN10CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="calc_n_clean_in10cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN5CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="calc_n_clean_in5cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CALC_N_CLEANED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_should_be_calc_n_cleaned", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_OVERDUE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_calc_n_clean_overdue", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_BLOCKAGE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_calc_n_clean_blockage", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="freezer_door_alarm", + appliance_types=("FridgeFreezer", "Freezer"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="refrigerator_door_alarm", + appliance_types=("FridgeFreezer", "Refrigerator"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="freezer_temperature_alarm", + appliance_types=("FridgeFreezer", "Freezer"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_EMPTY_DUST_BOX_AND_CLEAN_FILTER, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="empty_dust_box_and_clean_filter", + appliance_types=("CleaningRobot",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_ROBOT_IS_STUCK, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="robot_is_stuck", + appliance_types=("CleaningRobot",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_DOCKING_STATION_NOT_FOUND, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="docking_station_not_found", + appliance_types=("CleaningRobot",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="poor_i_dos_1_fill_level", + appliance_types=("Washer", "WasherDryer"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_2_FILL_LEVEL_POOR, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="poor_i_dos_2_fill_level", + appliance_types=("Washer", "WasherDryer"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_NEARLY_REACHED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="grease_filter_max_saturation_nearly_reached", + appliance_types=("Hood",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_REACHED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="grease_filter_max_saturation_reached", + appliance_types=("Hood",), + ), ) @@ -267,12 +479,6 @@ def _get_entities_for_appliance( ) -> list[HomeConnectEntity]: """Get a list of entities.""" return [ - *[ - HomeConnectEventSensor(entry.runtime_data, appliance, description) - for description in EVENT_SENSORS - if description.appliance_types - and appliance.info.type in description.appliance_types - ], *[ HomeConnectProgramSensor(entry.runtime_data, appliance, desc) for desc in BSH_PROGRAM_SENSORS @@ -286,6 +492,72 @@ def _get_entities_for_appliance( ] +def _add_event_sensor_entity( + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + appliance: HomeConnectApplianceData, + description: HomeConnectSensorEntityDescription, + remove_event_sensor_listener_list: list[Callable[[], None]], +) -> None: + """Add an event sensor entity.""" + if ( + (appliance_data := entry.runtime_data.data.get(appliance.info.ha_id)) is None + ) or description.key not in appliance_data.events: + return + + for remove_listener in remove_event_sensor_listener_list: + remove_listener() + async_add_entities( + [ + HomeConnectEventSensor(entry.runtime_data, appliance, description), + ] + ) + + +def _add_event_sensor_listeners( + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]], +) -> None: + for appliance in entry.runtime_data.data.values(): + if appliance.info.ha_id in remove_event_sensor_listener_dict: + continue + for event_sensor_description in EVENT_SENSORS: + if appliance.info.type not in cast( + tuple[str, ...], event_sensor_description.appliance_types + ): + continue + # We use a list as a kind of lazy initializer, as we can use the + # remove_listener while we are initializing it. + remove_event_sensor_listener_list = remove_event_sensor_listener_dict[ + appliance.info.ha_id + ] + remove_listener = entry.runtime_data.async_add_listener( + partial( + _add_event_sensor_entity, + entry, + async_add_entities, + appliance, + event_sensor_description, + remove_event_sensor_listener_list, + ), + (appliance.info.ha_id, event_sensor_description.key), + ) + remove_event_sensor_listener_list.append(remove_listener) + entry.async_on_unload(remove_listener) + + +def _remove_event_sensor_listeners_on_depaired( + entry: HomeConnectConfigEntry, + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]], +) -> None: + registered_listeners_ha_id = set(remove_event_sensor_listener_dict) + actual_appliances = set(entry.runtime_data.data) + for appliance_ha_id in registered_listeners_ha_id - actual_appliances: + for listener in remove_event_sensor_listener_dict.pop(appliance_ha_id): + listener() + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -298,6 +570,32 @@ async def async_setup_entry( async_add_entities, ) + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]] = defaultdict( + list + ) + + entry.async_on_unload( + entry.runtime_data.async_add_special_listener( + partial( + _add_event_sensor_listeners, + entry, + async_add_entities, + remove_event_sensor_listener_dict, + ), + (EventKey.BSH_COMMON_APPLIANCE_PAIRED,), + ) + ) + entry.async_on_unload( + entry.runtime_data.async_add_special_listener( + partial( + _remove_event_sensor_listeners_on_depaired, + entry, + remove_event_sensor_listener_dict, + ), + (EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,), + ) + ) + class HomeConnectSensor(HomeConnectEntity, SensorEntity): """Sensor class for Home Connect.""" @@ -402,8 +700,5 @@ class HomeConnectEventSensor(HomeConnectSensor): def update_native_value(self) -> None: """Update the sensor's status.""" - event = self.appliance.events.get(cast(EventKey, self.bsh_key)) - if event: - self._update_native_value(event.value) - elif not self._attr_native_value: - self._attr_native_value = self.entity_description.default_value + event = self.appliance.events[cast(EventKey, self.bsh_key)] + self._update_native_value(event.value) diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py new file mode 100644 index 00000000000..fac1c5fe1a9 --- /dev/null +++ b/homeassistant/components/home_connect/services.py @@ -0,0 +1,572 @@ +"""Custom actions (previously known as services) for the Home Connect integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable +from typing import Any, cast + +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + ArrayOfOptions, + CommandKey, + Option, + OptionKey, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import ( + AFFECTS_TO_ACTIVE_PROGRAM, + AFFECTS_TO_SELECTED_PROGRAM, + ATTR_AFFECTS_TO, + ATTR_KEY, + ATTR_PROGRAM, + ATTR_UNIT, + ATTR_VALUE, + DOMAIN, + PROGRAM_ENUM_OPTIONS, + SERVICE_OPTION_ACTIVE, + SERVICE_OPTION_SELECTED, + SERVICE_PAUSE_PROGRAM, + SERVICE_RESUME_PROGRAM, + SERVICE_SELECT_PROGRAM, + SERVICE_SET_PROGRAM_AND_OPTIONS, + SERVICE_SETTING, + SERVICE_START_PROGRAM, + TRANSLATION_KEYS_PROGRAMS_MAP, +) +from .coordinator import HomeConnectConfigEntry +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +PROGRAM_OPTIONS = { + bsh_key_to_translation_key(key): ( + key, + value, + ) + for key, value in { + OptionKey.BSH_COMMON_DURATION: int, + OptionKey.BSH_COMMON_START_IN_RELATIVE: int, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, + OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, + OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool, + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool, + OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, + OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, + }.items() +} + + +SERVICE_SETTING_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(SettingKey), + vol.NotIn([SettingKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + } +) + +# DEPRECATED: Remove in 2025.9.0 +SERVICE_OPTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(OptionKey), + vol.NotIn([OptionKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + vol.Optional(ATTR_UNIT): str, + } +) + +# DEPRECATED: Remove in 2025.9.0 +SERVICE_PROGRAM_SCHEMA = vol.Any( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): vol.All( + vol.Coerce(ProgramKey), + vol.NotIn([ProgramKey.UNKNOWN]), + ), + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(OptionKey), + vol.NotIn([OptionKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(int, str), + vol.Optional(ATTR_UNIT): str, + }, + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): vol.All( + vol.Coerce(ProgramKey), + vol.NotIn([ProgramKey.UNKNOWN]), + ), + }, +) + + +def _require_program_or_at_least_one_option(data: dict) -> dict: + if ATTR_PROGRAM not in data and not any( + option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS) + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="required_program_or_one_option_at_least", + ) + return data + + +SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_AFFECTS_TO): vol.In( + [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM] + ), + vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()), + } + ) + .extend( + { + vol.Optional(translation_key): vol.In(allowed_values.keys()) + for translation_key, ( + key, + allowed_values, + ) in PROGRAM_ENUM_OPTIONS.items() + } + ) + .extend( + { + vol.Optional(translation_key): schema + for translation_key, (key, schema) in PROGRAM_OPTIONS.items() + } + ), + _require_program_or_at_least_one_option, +) + +SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) + + +async def _get_client_and_ha_id( + hass: HomeAssistant, device_id: str +) -> tuple[HomeConnectClient, str]: + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + entry: HomeConnectConfigEntry | None = None + for entry_id in device_entry.config_entries: + _entry = hass.config_entries.async_get_entry(entry_id) + assert _entry + if _entry.domain == DOMAIN: + entry = cast(HomeConnectConfigEntry, _entry) + break + if entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + + ha_id = next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if ha_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="appliance_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + return entry.runtime_data.client, ha_id + + +async def _async_service_program(call: ServiceCall, start: bool) -> None: + """Execute calls to services taking a program.""" + program = call.data[ATTR_PROGRAM] + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + option_key = call.data.get(ATTR_KEY) + options = ( + [ + Option( + option_key, + call.data[ATTR_VALUE], + unit=call.data.get(ATTR_UNIT), + ) + ] + if option_key is not None + else None + ) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_PROGRAM}: {program}", + *([f" {ATTR_KEY}: {options[0].key}"] if options else []), + *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), + *( + [f" {ATTR_UNIT}: {options[0].unit}"] + if options and options[0].unit + else [] + ), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", + f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", + *( + [ + f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" + ] + if options + else [] + ), + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + + try: + if start: + await client.start_program(ha_id, program_key=program, options=options) + else: + await client.set_selected_program( + ha_id, program_key=program, options=options + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_program" if start else "select_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "program": program, + }, + ) from err + + +async def _async_service_set_program_options(call: ServiceCall, active: bool) -> None: + """Execute calls to services taking a program.""" + option_key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + unit = call.data.get(ATTR_UNIT) + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_KEY}: {option_key}", + f" {ATTR_VALUE}: {value}", + *([f" {ATTR_UNIT}: {unit}"] if unit else []), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", + f" {bsh_key_to_translation_key(option_key)}: {value}", + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + try: + if active: + await client.set_active_program_option( + ha_id, + option_key=option_key, + value=value, + unit=unit, + ) + else: + await client.set_selected_program_option( + ha_id, + option_key=option_key, + value=value, + unit=unit, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_options_active_program" + if active + else "set_options_selected_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "key": option_key, + "value": str(value), + }, + ) from err + + +async def _async_service_command(call: ServiceCall, command_key: CommandKey) -> None: + """Execute calls to services executing a command.""" + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_command_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_command_actions", + ) + + try: + await client.put_command(ha_id, command_key=command_key, value=True) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "command": command_key.value, + }, + ) from err + + +async def async_service_option_active(call: ServiceCall) -> None: + """Service for setting an option for an active program.""" + await _async_service_set_program_options(call, True) + + +async def async_service_option_selected(call: ServiceCall) -> None: + """Service for setting an option for a selected program.""" + await _async_service_set_program_options(call, False) + + +async def async_service_setting(call: ServiceCall) -> None: + """Service for changing a setting.""" + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + try: + await client.set_setting(ha_id, setting_key=key, value=value) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_setting", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "key": key, + "value": str(value), + }, + ) from err + + +async def async_service_pause_program(call: ServiceCall) -> None: + """Service for pausing a program.""" + await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) + + +async def async_service_resume_program(call: ServiceCall) -> None: + """Service for resuming a paused program.""" + await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) + + +async def async_service_select_program(call: ServiceCall) -> None: + """Service for selecting a program.""" + await _async_service_program(call, False) + + +async def async_service_set_program_and_options(call: ServiceCall) -> None: + """Service for setting a program and options.""" + data = dict(call.data) + program = data.pop(ATTR_PROGRAM, None) + affects_to = data.pop(ATTR_AFFECTS_TO) + client, ha_id = await _get_client_and_ha_id(call.hass, data.pop(ATTR_DEVICE_ID)) + + options: list[Option] = [] + + for option, value in data.items(): + if option in PROGRAM_ENUM_OPTIONS: + options.append( + Option( + PROGRAM_ENUM_OPTIONS[option][0], + PROGRAM_ENUM_OPTIONS[option][1][value], + ) + ) + elif option in PROGRAM_OPTIONS: + option_key = PROGRAM_OPTIONS[option][0] + options.append(Option(option_key, value)) + + method_call: Awaitable[Any] + exception_translation_key: str + if program: + program = ( + program + if isinstance(program, ProgramKey) + else TRANSLATION_KEYS_PROGRAMS_MAP[program] + ) + + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.start_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "start_program" + elif affects_to == AFFECTS_TO_SELECTED_PROGRAM: + method_call = client.set_selected_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "select_program" + else: + array_of_options = ArrayOfOptions(options) + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.set_active_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_active_program" + else: + # affects_to is AFFECTS_TO_SELECTED_PROGRAM + method_call = client.set_selected_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_selected_program" + + try: + await method_call + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=exception_translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + **({"program": program} if program else {}), + }, + ) from err + + +async def async_service_start_program(call: ServiceCall) -> None: + """Service for starting a program.""" + await _async_service_program(call, True) + + +def register_actions(hass: HomeAssistant) -> None: + """Register custom actions.""" + + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_ACTIVE, + async_service_option_active, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_SELECTED, + async_service_option_selected, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_PAUSE_PROGRAM, + async_service_pause_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_RESUME_PROGRAM, + async_service_resume_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SELECT_PROGRAM, + async_service_select_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_START_PROGRAM, + async_service_start_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROGRAM_AND_OPTIONS, + async_service_set_program_and_options, + schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, + ) diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 2b53090fd34..e07e8e91457 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -64,7 +64,6 @@ set_program_and_options: - selected_program program: example: dishcare_dishwasher_program_auto2 - required: true selector: select: mode: dropdown diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 1b4c79f6092..5b52183fccf 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -110,17 +110,60 @@ } }, "issues": { - "deprecated_binary_common_door_sensor": { - "title": "Deprecated binary door sensor detected in some automations or scripts", - "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." + "deprecated_time_alarm_clock_in_automations_scripts": { + "title": "Deprecated alarm clock entity detected in some automations or scripts", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_time_alarm_clock_in_automations_scripts::title%]", + "description": "The alarm clock entity `{entity_id}`, which is deprecated because it's being moved to the `number` platform, is used in the following automations or scripts:\n{items}\n\nPlease, fix this issue by updating your automations or scripts to use the new `number` entity." + } + } + } + }, + "deprecated_time_alarm_clock": { + "title": "Deprecated alarm clock entity", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_time_alarm_clock::title%]", + "description": "The alarm clock entity `{entity_id}` is deprecated because it's being moved to the `number` platform.\n\nPlease use the new `number` entity." + } + } + } }, "deprecated_command_actions": { "title": "The command related actions are deprecated in favor of the new buttons", - "description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue." + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_command_actions::title%]", + "description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue." + } + } + } + }, + "deprecated_program_switch_in_automations_scripts": { + "title": "Deprecated program switch detected in some automations or scripts", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_program_switch_in_automations_scripts::title%]", + "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue." + } + } + } }, "deprecated_program_switch": { - "title": "Deprecated program switch detected in some automations or scripts", - "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue." + "title": "Deprecated program switch entities", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_program_switch::title%]", + "description": "The switch entity `{entity_id}` and all the other program switches are deprecated.\n\nPlease use the active program select entity instead." + } + } + } }, "deprecated_set_program_and_option_actions": { "title": "The executed action is deprecated", @@ -433,9 +476,9 @@ }, "warming_level": { "options": { - "cooking_oven_enum_type_warming_level_low": "Low", - "cooking_oven_enum_type_warming_level_medium": "Medium", - "cooking_oven_enum_type_warming_level_high": "High" + "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]" } }, "washer_temperature": { @@ -457,7 +500,7 @@ }, "spin_speed": { "options": { - "laundry_care_washer_enum_type_spin_speed_off": "Off", + "laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm", @@ -467,15 +510,15 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm", - "laundry_care_washer_enum_type_spin_speed_ul_off": "Off", - "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", - "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", - "laundry_care_washer_enum_type_spin_speed_ul_high": "High" + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]" } }, "vario_perfect": { "options": { - "laundry_care_common_enum_type_vario_perfect_off": "Off", + "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]", "laundry_care_common_enum_type_vario_perfect_eco_perfect": "Eco perfect", "laundry_care_common_enum_type_vario_perfect_speed_perfect": "Speed perfect" } @@ -811,9 +854,18 @@ "bottle_cooler_door": { "name": "Bottle cooler door" }, + "common_chiller_door": { + "name": "Common chiller door" + }, "chiller_door": { "name": "Chiller door" }, + "left_chiller_door": { + "name": "Left chiller door" + }, + "right_chiller_door": { + "name": "Right chiller door" + }, "flex_compartment_door": { "name": "Flex compartment door" }, @@ -859,6 +911,9 @@ } }, "number": { + "alarm_clock": { + "name": "Alarm clock" + }, "refrigerator_setpoint_temperature": { "name": "Refrigerator temperature" }, @@ -1402,9 +1457,9 @@ "warming_level": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]", "state": { - "cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]", - "cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]", - "cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]" + "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]" } }, "washer_temperature": { @@ -1428,7 +1483,7 @@ "spin_speed": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]", "state": { - "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", + "laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]", @@ -1438,16 +1493,16 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]", - "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]", - "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", - "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", - "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]" + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]" } }, "vario_perfect": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]", "state": { - "laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]", + "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]", "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]", "laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]" } @@ -1536,23 +1591,64 @@ "oven_current_cavity_temperature": { "name": "Current oven cavity temperature" }, - "freezer_door_alarm": { - "name": "Freezer door alarm", - "state": { - "confirmed": "[%key:component::home_connect::common::confirmed%]", - "present": "[%key:component::home_connect::common::present%]" - } - }, - "refrigerator_door_alarm": { - "name": "Refrigerator door alarm", + "program_aborted": { + "name": "Program aborted", "state": { "off": "[%key:common::state::off%]", "confirmed": "[%key:component::home_connect::common::confirmed%]", "present": "[%key:component::home_connect::common::present%]" } }, - "freezer_temperature_alarm": { - "name": "Freezer temperature alarm", + "program_finished": { + "name": "Program finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "alarm_clock_elapsed": { + "name": "Alarm clock elapsed", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "preheat_finished": { + "name": "Pre-heat finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "regular_preheat_finished": { + "name": "Regular pre-heat finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "drying_process_finished": { + "name": "Drying process finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "salt_nearly_empty": { + "name": "Salt nearly empty", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "rinse_aid_nearly_empty": { + "name": "Rinse aid nearly empty", "state": { "off": "[%key:common::state::off%]", "confirmed": "[%key:component::home_connect::common::confirmed%]", @@ -1583,16 +1679,216 @@ "present": "[%key:component::home_connect::common::present%]" } }, - "salt_nearly_empty": { - "name": "Salt nearly empty", + "keep_milk_tank_cool": { + "name": "Keep milk tank cool", "state": { "off": "[%key:common::state::off%]", "confirmed": "[%key:component::home_connect::common::confirmed%]", "present": "[%key:component::home_connect::common::present%]" } }, - "rinse_aid_nearly_empty": { - "name": "Rinse aid nearly empty", + "descaling_in_20_cups": { + "name": "Descaling in 20 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "descaling_in_15_cups": { + "name": "Descaling in 15 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "descaling_in_10_cups": { + "name": "Descaling in 10 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "descaling_in_5_cups": { + "name": "Descaling in 5 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_should_be_descaled": { + "name": "Device should be descaled", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_descaling_overdue": { + "name": "Device descaling overdue", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_descaling_blockage": { + "name": "Device descaling blockage", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_should_be_cleaned": { + "name": "Device should be cleaned", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_cleaning_overdue": { + "name": "Device cleaning overdue", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in20cups": { + "name": "Calc'N'Clean in 20 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in15cups": { + "name": "Calc'N'Clean in 15 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in10cups": { + "name": "Calc'N'Clean in 10 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in5cups": { + "name": "Calc'N'Clean in 5 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_should_be_calc_n_cleaned": { + "name": "Device should be Calc'N'Cleaned", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_calc_n_clean_overdue": { + "name": "Device Calc'N'Clean overdue", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_calc_n_clean_blockage": { + "name": "Device Calc'N'Clean blockage", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "freezer_door_alarm": { + "name": "Freezer door alarm", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "refrigerator_door_alarm": { + "name": "Refrigerator door alarm", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "freezer_temperature_alarm": { + "name": "Freezer temperature alarm", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "empty_dust_box_and_clean_filter": { + "name": "Empty dust box and clean filter", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "robot_is_stuck": { + "name": "Robot is stuck", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "docking_station_not_found": { + "name": "Docking station not found", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "poor_i_dos_1_fill_level": { + "name": "Poor i-Dos 1 fill level", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "poor_i_dos_2_fill_level": { + "name": "Poor i-Dos 2 fill level", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "grease_filter_max_saturation_nearly_reached": { + "name": "Grease filter max saturation nearly reached", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "grease_filter_max_saturation_reached": { + "name": "Grease filter max saturation reached", "state": { "off": "[%key:common::state::off%]", "confirmed": "[%key:component::home_connect::common::confirmed%]", diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 6f9aa0e679f..05f0ed2ddc3 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -22,16 +22,7 @@ from homeassistant.helpers.issue_registry import ( from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .common import setup_home_connect_entry -from .const import ( - BSH_POWER_OFF, - BSH_POWER_ON, - BSH_POWER_STANDBY, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, -) +from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, @@ -226,8 +217,8 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_key="turn_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, + "entity_id": self.entity_id, + "key": self.bsh_key, }, ) from err @@ -246,8 +237,8 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_key="turn_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, + "entity_id": self.entity_id, + "key": self.bsh_key, }, ) from err @@ -275,7 +266,10 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): super().__init__( coordinator, appliance, - SwitchEntityDescription(key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM), + SwitchEntityDescription( + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + entity_registry_enabled_default=False, + ), ) self._attr_name = f"{appliance.info.name} {desc}" self._attr_unique_id = f"{appliance.info.ha_id}-{desc}" @@ -313,11 +307,12 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async_create_issue( self.hass, DOMAIN, - f"deprecated_program_switch_{self.entity_id}", + f"deprecated_program_switch_in_automations_scripts_{self.entity_id}", breaks_in_ha_version="2025.6.0", - is_fixable=False, + is_fixable=True, + is_persistent=True, severity=IssueSeverity.WARNING, - translation_key="deprecated_program_switch", + translation_key="deprecated_program_switch_in_automations_scripts", translation_placeholders={ "entity_id": self.entity_id, "items": "\n".join(items_list), @@ -326,12 +321,34 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed from hass.""" + async_delete_issue( + self.hass, + DOMAIN, + f"deprecated_program_switch_in_automations_scripts_{self.entity_id}", + ) async_delete_issue( self.hass, DOMAIN, f"deprecated_program_switch_{self.entity_id}" ) + def create_action_handler_issue(self) -> None: + """Create deprecation issue.""" + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_program_switch_{self.entity_id}", + breaks_in_ha_version="2025.6.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_program_switch", + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) + async def async_turn_on(self, **kwargs: Any) -> None: """Start the program.""" + self.create_action_handler_issue() try: await self.coordinator.client.start_program( self.appliance.info.ha_id, program_key=self.program.key @@ -348,6 +365,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Stop the program.""" + self.create_action_handler_issue() try: await self.coordinator.client.stop_program(self.appliance.info.ha_id) except HomeConnectError as err: @@ -385,7 +403,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, + "appliance_name": self.appliance.info.name, }, ) from err @@ -398,7 +416,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_domain=DOMAIN, translation_key="unable_to_retrieve_turn_off", translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name + "appliance_name": self.appliance.info.name }, ) @@ -406,9 +424,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off_not_supported", - translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name - }, + translation_placeholders={"appliance_name": self.appliance.info.name}, ) try: await self.coordinator.client.set_setting( @@ -423,8 +439,8 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, - SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state, + "appliance_name": self.appliance.info.name, + "value": self.power_off_state, }, ) from err diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index a1761219d30..adf26d2d973 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -1,4 +1,4 @@ -"""Provides time enties for Home Connect.""" +"""Provides time entities for Home Connect.""" from datetime import time from typing import cast @@ -6,19 +6,21 @@ from typing import cast from aiohomeconnect.model import SettingKey from aiohomeconnect.model.error import HomeConnectError +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from .common import setup_home_connect_entry -from .const import ( - DOMAIN, - SVE_TRANSLATION_KEY_SET_SETTING, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, -) +from .const import DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error @@ -29,6 +31,7 @@ TIME_ENTITIES = ( TimeEntityDescription( key=SettingKey.BSH_COMMON_ALARM_CLOCK, translation_key="alarm_clock", + entity_registry_enabled_default=False, ), ) @@ -73,8 +76,78 @@ def time_to_seconds(t: time) -> int: class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): """Time setting class for Home Connect.""" + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + if self.bsh_key == SettingKey.BSH_COMMON_ALARM_CLOCK: + automations = automations_with_entity(self.hass, self.entity_id) + scripts = scripts_with_entity(self.hass, self.entity_id) + items = automations + scripts + if not items: + return + + entity_reg: er.EntityRegistry = er.async_get(self.hass) + entity_automations = [ + automation_entity + for automation_id in automations + if (automation_entity := entity_reg.async_get(automation_id)) + ] + entity_scripts = [ + script_entity + for script_id in scripts + if (script_entity := entity_reg.async_get(script_id)) + ] + + items_list = [ + f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" + for item in entity_automations + ] + [ + f"- [{item.original_name}](/config/script/edit/{item.unique_id})" + for item in entity_scripts + ] + + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_time_alarm_clock_in_automations_scripts_{self.entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_time_alarm_clock", + translation_placeholders={ + "entity_id": self.entity_id, + "items": "\n".join(items_list), + }, + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + if self.bsh_key == SettingKey.BSH_COMMON_ALARM_CLOCK: + async_delete_issue( + self.hass, + DOMAIN, + f"deprecated_time_alarm_clock_in_automations_scripts_{self.entity_id}", + ) + async_delete_issue( + self.hass, DOMAIN, f"deprecated_time_alarm_clock_{self.entity_id}" + ) + async def async_set_value(self, value: time) -> None: """Set the native value of the entity.""" + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_time_alarm_clock_{self.entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_time_alarm_clock", + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) try: await self.coordinator.client.set_setting( self.appliance.info.ha_id, @@ -84,12 +157,12 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": str(value), }, ) from err diff --git a/homeassistant/components/homeassistant_hardware/coordinator.py b/homeassistant/components/homeassistant_hardware/coordinator.py index 9eb900b13fd..c9a5c891328 100644 --- a/homeassistant/components/homeassistant_hardware/coordinator.py +++ b/homeassistant/components/homeassistant_hardware/coordinator.py @@ -31,7 +31,6 @@ class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]): _LOGGER, name="firmware update coordinator", update_interval=FIRMWARE_REFRESH_INTERVAL, - always_update=False, ) self.hass = hass self.session = session diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 83031587712..1b4840e5a98 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -33,6 +33,7 @@ from .util import ( OwningIntegration, get_otbr_addon_manager, get_zigbee_flasher_addon_manager, + guess_firmware_info, guess_hardware_owners, probe_silabs_firmware_info, ) @@ -511,6 +512,16 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm a discovery.""" + assert self._device is not None + fw_info = await guess_firmware_info(self.hass, self._device) + + # If our guess for the firmware type is actually running, we can save the user + # an unnecessary confirmation and silently confirm the flow + for owner in fw_info.owners: + if await owner.is_running(self.hass): + self._probed_firmware_info = fw_info + return self._async_flow_finished() + return await self.async_step_pick_firmware() diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 5456f418c75..6dda01561f1 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -39,7 +39,7 @@ "description": "The OpenThread Border Router (OTBR) add-on is now starting." }, "otbr_failed": { - "title": "Failed to setup OpenThread Border Router", + "title": "Failed to set up OpenThread Border Router", "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists." }, "confirm_otbr": { diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py index e835286238f..1b0f15ca021 100644 --- a/homeassistant/components/homeassistant_hardware/update.py +++ b/homeassistant/components/homeassistant_hardware/update.py @@ -95,8 +95,7 @@ class BaseFirmwareUpdateEntity( _attr_supported_features = ( UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS ) - # Until this entity can be associated with a device, we must manually name it - _attr_has_entity_name = False + _attr_has_entity_name = True def __init__( self, @@ -195,11 +194,7 @@ class BaseFirmwareUpdateEntity( def _update_attributes(self) -> None: """Recompute the attributes of the entity.""" - - # This entity is not currently associated with a device so we must manually - # give it a name - self._attr_name = f"{self._config_entry.title} Update" - self._attr_title = self.entity_description.firmware_name or "unknown" + self._attr_title = self.entity_description.firmware_name or "Unknown" if ( self._current_firmware_info is None diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index b3af47df61d..dfc129ddc75 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -3,19 +3,79 @@ from __future__ import annotations import logging +import os.path from homeassistant.components.homeassistant_hardware.util import guess_firmware_info +from homeassistant.components.usb import ( + USBDevice, + async_register_port_event_callback, + scan_serial_ports, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import DESCRIPTION, DEVICE, FIRMWARE, FIRMWARE_VERSION, PRODUCT +from .const import ( + DESCRIPTION, + DEVICE, + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + MANUFACTURER, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, +) _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the ZBT-1 integration.""" + + @callback + def async_port_event_callback( + added: set[USBDevice], removed: set[USBDevice] + ) -> None: + """Handle USB port events.""" + current_entries_by_path = { + entry.data[DEVICE]: entry + for entry in hass.config_entries.async_entries(DOMAIN) + } + + for device in added | removed: + path = device.device + entry = current_entries_by_path.get(path) + + if entry is not None: + _LOGGER.debug( + "Device %r has changed state, reloading config entry %s", + path, + entry, + ) + hass.config_entries.async_schedule_reload(entry.entry_id) + + async_register_port_event_callback(hass, async_port_event_callback) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant SkyConnect config entry.""" + # Postpone loading the config entry if the device is missing + device_path = entry.data[DEVICE] + if not await hass.async_add_executor_job(os.path.exists, device_path): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_disconnected", + ) + await hass.config_entries.async_forward_entry_setups(entry, ["update"]) return True @@ -23,6 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + await hass.config_entries.async_unload_platforms(entry, ["update"]) return True @@ -30,7 +91,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> """Migrate old entry.""" _LOGGER.debug( - "Migrating from version %s:%s", config_entry.version, config_entry.minor_version + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) if config_entry.version == 1: @@ -65,6 +126,43 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> minor_version=3, ) + if config_entry.minor_version == 3: + # Old SkyConnect config entries were missing keys + if any( + key not in config_entry.data + for key in (VID, PID, MANUFACTURER, PRODUCT, SERIAL_NUMBER) + ): + serial_ports = await hass.async_add_executor_job(scan_serial_ports) + serial_ports_info = {port.device: port for port in serial_ports} + device = config_entry.data[DEVICE] + + if not (usb_info := serial_ports_info.get(device)): + raise HomeAssistantError( + f"USB device {device} is missing, cannot migrate" + ) + + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + VID: usb_info.vid, + PID: usb_info.pid, + MANUFACTURER: usb_info.manufacturer, + PRODUCT: usb_info.description, + DESCRIPTION: usb_info.description, + SERIAL_NUMBER: usb_info.serial_number, + }, + version=1, + minor_version=4, + ) + else: + # Existing entries are migrated by just incrementing the version + hass.config_entries.async_update_entry( + config_entry, + version=1, + minor_version=4, + ) + _LOGGER.debug( "Migration to version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index d28d74a681c..eb5ea214b3e 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -81,7 +81,7 @@ class HomeAssistantSkyConnectConfigFlow( """Handle a config flow for Home Assistant SkyConnect.""" VERSION = 1 - MINOR_VERSION = 3 + MINOR_VERSION = 4 def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the config flow.""" diff --git a/homeassistant/components/homeassistant_sky_connect/hardware.py b/homeassistant/components/homeassistant_sky_connect/hardware.py index 2872077111a..9bfa5d16655 100644 --- a/homeassistant/components/homeassistant_sky_connect/hardware.py +++ b/homeassistant/components/homeassistant_sky_connect/hardware.py @@ -5,17 +5,21 @@ from __future__ import annotations from homeassistant.components.hardware.models import HardwareInfo, USBInfo from homeassistant.core import HomeAssistant, callback +from .config_flow import HomeAssistantSkyConnectConfigFlow from .const import DOMAIN from .util import get_hardware_variant DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/" +EXPECTED_ENTRY_VERSION = ( + HomeAssistantSkyConnectConfigFlow.VERSION, + HomeAssistantSkyConnectConfigFlow.MINOR_VERSION, +) @callback def async_info(hass: HomeAssistant) -> list[HardwareInfo]: """Return board info.""" entries = hass.config_entries.async_entries(DOMAIN) - return [ HardwareInfo( board=None, @@ -31,4 +35,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]: url=DOCUMENTATION_URL, ) for entry in entries + # Ignore unmigrated config entries in the hardware page + if (entry.version, entry.minor_version) == EXPECTED_ENTRY_VERSION ] diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index a596b9846ce..a990f025e8d 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -195,5 +195,10 @@ "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" } + }, + "exceptions": { + "device_disconnected": { + "message": "The device is not plugged in" + } } } diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py index 43e3f1ca255..74c28b37eaf 100644 --- a/homeassistant/components/homeassistant_sky_connect/update.py +++ b/homeassistant/components/homeassistant_sky_connect/update.py @@ -21,11 +21,20 @@ from homeassistant.components.update import UpdateDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import FIRMWARE, FIRMWARE_VERSION, NABU_CASA_FIRMWARE_RELEASES_URL +from .const import ( + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + NABU_CASA_FIRMWARE_RELEASES_URL, + PRODUCT, + SERIAL_NUMBER, + HardwareVariant, +) _LOGGER = logging.getLogger(__name__) @@ -42,7 +51,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ fw_type="skyconnect_zigbee_ncp", version_key="ezsp_version", expected_firmware_type=ApplicationType.EZSP, - firmware_name="EmberZNet", + firmware_name="EmberZNet Zigbee", ), ApplicationType.SPINEL: FirmwareUpdateEntityDescription( key="firmware", @@ -55,6 +64,28 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ expected_firmware_type=ApplicationType.SPINEL, firmware_name="OpenThread RCP", ), + ApplicationType.CPC: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type="skyconnect_multipan", + version_key="cpc_version", + expected_firmware_type=ApplicationType.CPC, + firmware_name="Multiprotocol", + ), + ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, # We don't want to update the bootloader + version_key="gecko_bootloader_version", + expected_firmware_type=ApplicationType.GECKO_BOOTLOADER, + firmware_name="Gecko Bootloader", + ), None: FirmwareUpdateEntityDescription( key="firmware", display_precision=0, @@ -77,9 +108,16 @@ def _async_create_update_entity( ) -> FirmwareUpdateEntity: """Create an update entity that handles firmware type changes.""" firmware_type = config_entry.data[FIRMWARE] - entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[ - ApplicationType(firmware_type) if firmware_type is not None else None - ] + + try: + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) + ] + except (KeyError, ValueError): + _LOGGER.debug( + "Unknown firmware type %r, using default entity description", firmware_type + ) + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None] entity = FirmwareUpdateEntity( device=config_entry.data["device"], @@ -141,8 +179,18 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): """Initialize the SkyConnect firmware update entity.""" super().__init__(device, config_entry, update_coordinator, entity_description) - self._attr_unique_id = ( - f"{self._config_entry.data['serial_number']}_{self.entity_description.key}" + variant = HardwareVariant.from_usb_product_name( + self._config_entry.data[PRODUCT] + ) + serial_number = self._config_entry.data[SERIAL_NUMBER] + + self._attr_unique_id = f"{serial_number}_{self.entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + name=f"{variant.full_name} ({serial_number[:8]})", + model=variant.full_name, + manufacturer="Nabu Casa", + serial_number=serial_number, ) # Use the cached firmware info if it exists @@ -155,6 +203,17 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): source="homeassistant_sky_connect", ) + def _update_attributes(self) -> None: + """Recompute the attributes of the entity.""" + super()._update_attributes() + + assert self.device_entry is not None + device_registry = dr.async_get(self.hass) + device_registry.async_update_device( + device_id=self.device_entry.id, + sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}", + ) + @callback def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: """Handle updated firmware info being pushed by an integration.""" diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 06f908ab61e..71aa8ef99b7 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -62,6 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + await hass.config_entries.async_unload_platforms(entry, ["update"]) return True diff --git a/homeassistant/components/homeassistant_yellow/const.py b/homeassistant/components/homeassistant_yellow/const.py index b98b1133d01..b8bf17391f9 100644 --- a/homeassistant/components/homeassistant_yellow/const.py +++ b/homeassistant/components/homeassistant_yellow/const.py @@ -2,8 +2,9 @@ DOMAIN = "homeassistant_yellow" -RADIO_MODEL = "Home Assistant Yellow" -RADIO_MANUFACTURER = "Nabu Casa" +MODEL = "Home Assistant Yellow" +MANUFACTURER = "Nabu Casa" + RADIO_DEVICE = "/dev/ttyAMA1" ZHA_HW_DISCOVERY_DATA = { diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index b089e483899..41c1438b234 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -149,5 +149,12 @@ "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" } + }, + "entity": { + "update": { + "radio_firmware": { + "name": "Radio firmware" + } + } } } diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py index 88d4f2912d3..9531bd456cb 100644 --- a/homeassistant/components/homeassistant_yellow/update.py +++ b/homeassistant/components/homeassistant_yellow/update.py @@ -21,13 +21,17 @@ from homeassistant.components.update import UpdateDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( + DOMAIN, FIRMWARE, FIRMWARE_VERSION, + MANUFACTURER, + MODEL, NABU_CASA_FIRMWARE_RELEASES_URL, RADIO_DEVICE, ) @@ -39,7 +43,8 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ ApplicationType | None, FirmwareUpdateEntityDescription ] = { ApplicationType.EZSP: FirmwareUpdateEntityDescription( - key="firmware", + key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -47,10 +52,11 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ fw_type="yellow_zigbee_ncp", version_key="ezsp_version", expected_firmware_type=ApplicationType.EZSP, - firmware_name="EmberZNet", + firmware_name="EmberZNet Zigbee", ), ApplicationType.SPINEL: FirmwareUpdateEntityDescription( - key="firmware", + key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -60,8 +66,33 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ expected_firmware_type=ApplicationType.SPINEL, firmware_name="OpenThread RCP", ), + ApplicationType.CPC: FirmwareUpdateEntityDescription( + key="radio_firmware", + translation_key="radio_firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type="yellow_multipan", + version_key="cpc_version", + expected_firmware_type=ApplicationType.CPC, + firmware_name="Multiprotocol", + ), + ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription( + key="radio_firmware", + translation_key="radio_firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, # We don't want to update the bootloader + version_key="gecko_bootloader_version", + expected_firmware_type=ApplicationType.GECKO_BOOTLOADER, + firmware_name="Gecko Bootloader", + ), None: FirmwareUpdateEntityDescription( - key="firmware", + key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -82,9 +113,16 @@ def _async_create_update_entity( ) -> FirmwareUpdateEntity: """Create an update entity that handles firmware type changes.""" firmware_type = config_entry.data[FIRMWARE] - entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[ - ApplicationType(firmware_type) if firmware_type is not None else None - ] + + try: + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) + ] + except (KeyError, ValueError): + _LOGGER.debug( + "Unknown firmware type %r, using default entity description", firmware_type + ) + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None] entity = FirmwareUpdateEntity( device=RADIO_DEVICE, @@ -145,8 +183,13 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): ) -> None: """Initialize the Yellow firmware update entity.""" super().__init__(device, config_entry, update_coordinator, entity_description) - self._attr_unique_id = self.entity_description.key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, "yellow")}, + name=MODEL, + model=MODEL, + manufacturer=MANUFACTURER, + ) # Use the cached firmware info if it exists if self._config_entry.data[FIRMWARE] is not None: @@ -158,6 +201,17 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): source="homeassistant_yellow", ) + def _update_attributes(self) -> None: + """Recompute the attributes of the entity.""" + super()._update_attributes() + + assert self.device_entry is not None + device_registry = dr.async_get(self.hass) + device_registry.async_update_device( + device_id=self.device_entry.id, + sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}", + ) + @callback def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: """Handle updated firmware info being pushed by an integration.""" diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 6158a699302..fbd34743496 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -17,8 +17,10 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.LIGHT, + Platform.LOCK, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/homee/climate.py b/homeassistant/components/homee/climate.py new file mode 100644 index 00000000000..3411d31461c --- /dev/null +++ b/homeassistant/components/homee/climate.py @@ -0,0 +1,200 @@ +"""The Homee climate platform.""" + +from typing import Any + +from pyHomee.const import AttributeType, NodeProfile +from pyHomee.model import HomeeNode + +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .const import CLIMATE_PROFILES, DOMAIN, HOMEE_UNIT_TO_HA_UNIT, PRESET_MANUAL +from .entity import HomeeNodeEntity + +PARALLEL_UPDATES = 0 + +ROOM_THERMOSTATS = { + NodeProfile.ROOM_THERMOSTAT, + NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR, + NodeProfile.WIFI_ROOM_THERMOSTAT, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the climate component.""" + + async_add_devices( + HomeeClimate(node, config_entry) + for node in config_entry.runtime_data.nodes + if node.profile in CLIMATE_PROFILES + ) + + +class HomeeClimate(HomeeNodeEntity, ClimateEntity): + """Representation of a Homee climate entity.""" + + _attr_name = None + _attr_translation_key = DOMAIN + + def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + """Initialize a Homee climate entity.""" + super().__init__(node, entry) + + ( + self._attr_supported_features, + self._attr_hvac_modes, + self._attr_preset_modes, + ) = get_climate_features(self._node) + + self._target_temp = self._node.get_attribute_by_type( + AttributeType.TARGET_TEMPERATURE + ) + assert self._target_temp is not None + self._attr_temperature_unit = str(HOMEE_UNIT_TO_HA_UNIT[self._target_temp.unit]) + self._attr_target_temperature_step = self._target_temp.step_value + self._attr_unique_id = f"{self._attr_unique_id}-{self._target_temp.id}" + + self._heating_mode = self._node.get_attribute_by_type( + AttributeType.HEATING_MODE + ) + self._temperature = self._node.get_attribute_by_type(AttributeType.TEMPERATURE) + self._valve_position = self._node.get_attribute_by_type( + AttributeType.CURRENT_VALVE_POSITION + ) + + @property + def hvac_mode(self) -> HVACMode: + """Return the hvac operation mode.""" + if ClimateEntityFeature.TURN_OFF in self.supported_features and ( + self._heating_mode is not None + ): + if self._heating_mode.current_value == 0: + return HVACMode.OFF + + return HVACMode.HEAT + + @property + def hvac_action(self) -> HVACAction: + """Return the hvac action.""" + if self._heating_mode is not None and self._heating_mode.current_value == 0: + return HVACAction.OFF + + if ( + self._valve_position is not None and self._valve_position.current_value == 0 + ) or ( + self._temperature is not None + and self._temperature.current_value >= self.target_temperature + ): + return HVACAction.IDLE + + return HVACAction.HEATING + + @property + def preset_mode(self) -> str: + """Return the present preset mode.""" + if ( + ClimateEntityFeature.PRESET_MODE in self.supported_features + and self._heating_mode is not None + and self._heating_mode.current_value > 0 + ): + assert self._attr_preset_modes is not None + return self._attr_preset_modes[int(self._heating_mode.current_value) - 1] + + return PRESET_NONE + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if self._temperature is not None: + return self._temperature.current_value + return None + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + assert self._target_temp is not None + return self._target_temp.current_value + + @property + def min_temp(self) -> float: + """Return the lowest settable target temperature.""" + assert self._target_temp is not None + return self._target_temp.minimum + + @property + def max_temp(self) -> float: + """Return the lowest settable target temperature.""" + assert self._target_temp is not None + return self._target_temp.maximum + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + # Currently only HEAT and OFF are supported. + assert self._heating_mode is not None + await self.async_set_homee_value( + self._heating_mode, float(hvac_mode == HVACMode.HEAT) + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + assert self._heating_mode is not None and self._attr_preset_modes is not None + await self.async_set_homee_value( + self._heating_mode, self._attr_preset_modes.index(preset_mode) + 1 + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + assert self._target_temp is not None + if ATTR_TEMPERATURE in kwargs: + await self.async_set_homee_value( + self._target_temp, kwargs[ATTR_TEMPERATURE] + ) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + assert self._heating_mode is not None + await self.async_set_homee_value(self._heating_mode, 1) + + async def async_turn_off(self) -> None: + """Turn the entity on.""" + assert self._heating_mode is not None + await self.async_set_homee_value(self._heating_mode, 0) + + +def get_climate_features( + node: HomeeNode, +) -> tuple[ClimateEntityFeature, list[HVACMode], list[str] | None]: + """Determine supported climate features of a node based on the available attributes.""" + features = ClimateEntityFeature.TARGET_TEMPERATURE + hvac_modes = [HVACMode.HEAT] + preset_modes: list[str] = [] + + if ( + attribute := node.get_attribute_by_type(AttributeType.HEATING_MODE) + ) is not None: + features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + hvac_modes.append(HVACMode.OFF) + + if attribute.maximum > 1: + # Node supports more modes than off and heating. + features |= ClimateEntityFeature.PRESET_MODE + preset_modes.extend([PRESET_ECO, PRESET_BOOST, PRESET_MANUAL]) + + if len(preset_modes) > 0: + preset_modes.insert(0, PRESET_NONE) + return (features, hvac_modes, preset_modes if len(preset_modes) > 0 else None) diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py index 61d2a3f25a5..1a3c5011f82 100644 --- a/homeassistant/components/homee/config_flow.py +++ b/homeassistant/components/homee/config_flow.py @@ -52,7 +52,7 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except HomeeAuthenticationFailedException: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 2c614d3f5eb..468fb2d49ac 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -95,3 +95,6 @@ LIGHT_PROFILES = [ NodeProfile.WIFI_DIMMABLE_LIGHT, NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH, ] + +# Climate Presets +PRESET_MANUAL = "manual" diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index b4ad8871568..d6d327a32c5 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -1,5 +1,16 @@ { "entity": { + "climate": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "mdi:hand-back-left" + } + } + } + } + }, "sensor": { "brightness": { "default": "mdi:brightness-5" diff --git a/homeassistant/components/homee/lock.py b/homeassistant/components/homee/lock.py new file mode 100644 index 00000000000..4cfc34e11fe --- /dev/null +++ b/homeassistant/components/homee/lock.py @@ -0,0 +1,73 @@ +"""The Homee lock platform.""" + +from typing import Any + +from pyHomee.const import AttributeChangedBy, AttributeType + +from homeassistant.components.lock import LockEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity +from .helpers import get_name_for_enum + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the lock component.""" + + async_add_devices( + HomeeLock(attribute, config_entry) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if (attribute.type == AttributeType.LOCK_STATE and attribute.editable) + ) + + +class HomeeLock(HomeeEntity, LockEntity): + """Representation of a Homee lock.""" + + _attr_name = None + + @property + def is_locked(self) -> bool: + """Return if lock is locked.""" + return self._attribute.current_value == 1.0 + + @property + def is_locking(self) -> bool: + """Return if lock is locking.""" + return self._attribute.target_value > self._attribute.current_value + + @property + def is_unlocking(self) -> bool: + """Return if lock is unlocking.""" + return self._attribute.target_value < self._attribute.current_value + + @property + def changed_by(self) -> str: + """Return by whom or what the lock was last changed.""" + changed_id = str(self._attribute.changed_by_id) + changed_by_name = get_name_for_enum( + AttributeChangedBy, self._attribute.changed_by + ) + if self._attribute.changed_by == AttributeChangedBy.USER: + changed_id = self._entry.runtime_data.get_user_by_id( + self._attribute.changed_by_id + ).username + + return f"{changed_by_name}-{changed_id}" + + async def async_lock(self, **kwargs: Any) -> None: + """Lock specified lock. A code to lock the lock with may be specified.""" + await self.async_set_homee_value(1) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock specified lock. A code to unlock the lock with may be specified.""" + await self.async_set_homee_value(0) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index da8357d16bc..806a21556cb 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "Homee {name} ({host})", + "flow_title": "homee {name} ({host})", "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, @@ -18,9 +18,9 @@ "username": "[%key:common::config_flow::data::username%]" }, "data_description": { - "host": "The IP address of your Homee.", - "username": "The username for your Homee.", - "password": "The password for your Homee." + "host": "The IP address of your homee.", + "username": "The username for your homee.", + "password": "The password for your homee." } } } @@ -45,7 +45,7 @@ "load_alarm": { "name": "Load", "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "on": "Overload" } }, @@ -131,6 +131,17 @@ "name": "Ventilate" } }, + "climate": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "Manual" + } + } + } + } + }, "light": { "light_instance": { "name": "Light {instance}" @@ -297,8 +308,8 @@ "open": "[%key:common::state::open%]", "closed": "[%key:common::state::closed%]", "partial": "Partially open", - "opening": "Opening", - "closing": "Closing" + "opening": "[%key:common::state::opening%]", + "closing": "[%key:common::state::closing%]" } }, "uv": { @@ -341,7 +352,7 @@ }, "exceptions": { "connection_closed": { - "message": "Could not connect to Homee while setting attribute." + "message": "Could not connect to homee while setting attribute." } } } diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 7341bbd3a4a..4c8bf8517be 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -659,13 +659,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): # e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit. # Can be 0 - 2 (Off, Heat, Cool) - # If the HVAC is switched off, it must be idle - # This works around a bug in some devices (like Eve radiator valves) that - # return they are heating when they are not. target = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) - if target == HeatingCoolingTargetValues.OFF: - return HVACAction.IDLE - value = self.service.value(CharacteristicsTypes.HEATING_COOLING_CURRENT) current_hass_value = CURRENT_MODE_HOMEKIT_TO_HASS.get(value) @@ -679,6 +673,12 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): ): return HVACAction.FAN + # If the HVAC is switched off, it must be idle + # This works around a bug in some devices (like Eve radiator valves) that + # return they are heating when they are not. + if target == HeatingCoolingTargetValues.OFF: + return HVACAction.IDLE + return current_hass_value @property diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 98db9a397d3..6562a3edcc9 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.2.8"], + "requirements": ["aiohomekit==3.2.13"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index d1205645fd3..dcbfae72fe3 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -141,7 +141,7 @@ "air_purifier_state_current": { "state": { "inactive": "Inactive", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "purifying": "Purifying" } } diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 24172e196c1..bdd446d7091 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -178,6 +178,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { key="WIND_DIRECTION", native_unit_of_measurement=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), "WIND_DIRECTION_RANGE": SensorEntityDescription( key="WIND_DIRECTION_RANGE", diff --git a/homeassistant/components/homematic/strings.json b/homeassistant/components/homematic/strings.json index d962a218a4f..78159189db8 100644 --- a/homeassistant/components/homematic/strings.json +++ b/homeassistant/components/homematic/strings.json @@ -2,7 +2,7 @@ "services": { "virtualkey": { "name": "Virtual key", - "description": "Presses a virtual key from CCU/Homegear or simulate keypress.", + "description": "Simulates a keypress (or other valid action) on CCU/Homegear with virtual or device keys.", "fields": { "address": { "name": "Address", @@ -24,7 +24,7 @@ }, "set_variable_value": { "name": "Set variable value", - "description": "Sets the name of a node.", + "description": "Sets the value of a system variable.", "fields": { "entity_id": { "name": "Entity", diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index 1a144615e89..3ec4945957b 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -57,7 +57,7 @@ }, "exceptions": { "invalid_controller_id": { - "message": "Invalid controller_id \"{controller_id}\", expected one of \"{controller_ids}\"" + "message": "Invalid controller ID \"{controller_id}\", expected one of \"{controller_ids}\"" } }, "options": { diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index 2538e7101a1..67295ec5802 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Please enter the credentials used to log into mytotalconnectcomfort.com.", + "description": "Please enter the credentials used to log in to mytotalconnectcomfort.com.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -55,7 +55,7 @@ "preset_mode": { "state": { "hold": "Hold", - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" } } diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py index ebc0594e15a..fdb325c7b74 100644 --- a/homeassistant/components/http/headers.py +++ b/homeassistant/components/http/headers.py @@ -3,25 +3,34 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from typing import Final +from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware from aiohttp.web_exceptions import HTTPException +from multidict import CIMultiDict, istr from homeassistant.core import callback +REFERRER_POLICY: Final[istr] = istr("Referrer-Policy") +X_CONTENT_TYPE_OPTIONS: Final[istr] = istr("X-Content-Type-Options") +X_FRAME_OPTIONS: Final[istr] = istr("X-Frame-Options") + @callback def setup_headers(app: Application, use_x_frame_options: bool) -> None: """Create headers middleware for the app.""" - added_headers = { - "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - "Server": "", # Empty server header, to prevent aiohttp of setting one. - } + added_headers = CIMultiDict( + { + REFERRER_POLICY: "no-referrer", + X_CONTENT_TYPE_OPTIONS: "nosniff", + hdrs.SERVER: "", # Empty server header, to prevent aiohttp of setting one. + } + ) if use_x_frame_options: - added_headers["X-Frame-Options"] = "SAMEORIGIN" + added_headers[X_FRAME_OPTIONS] = "SAMEORIGIN" @middleware async def headers_middleware( diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 96e160ece7b..4ca9e7531e3 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -178,8 +178,8 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): except Timeout: _LOGGER.warning("Connection timeout", exc_info=True) errors[CONF_URL] = "connection_timeout" - except Exception: # noqa: BLE001 - _LOGGER.warning("Unknown error connecting to device", exc_info=True) + except Exception: + _LOGGER.exception("Unknown error connecting to device") errors[CONF_URL] = "unknown" return conn @@ -188,8 +188,8 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): try: conn.close() conn.requests_session.close() - except Exception: # noqa: BLE001 - _LOGGER.debug("Disconnect error", exc_info=True) + except Exception: + _LOGGER.exception("Disconnect error") async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 6720d6718ef..ce5316553ed 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -9,7 +9,7 @@ "requirements": [ "huawei-lte-api==1.10.0", "stringcase==1.2.0", - "url-normalize==1.4.3" + "url-normalize==2.2.0" ], "ssdp": [ { diff --git a/homeassistant/components/hue/icons.json b/homeassistant/components/hue/icons.json index 31464308b0a..646c420f1fe 100644 --- a/homeassistant/components/hue/icons.json +++ b/homeassistant/components/hue/icons.json @@ -1,4 +1,28 @@ { + "entity": { + "light": { + "hue_light": { + "state_attributes": { + "effect": { + "state": { + "candle": "mdi:candle", + "sparkle": "mdi:shimmer", + "glisten": "mdi:creation", + "sunrise": "mdi:weather-sunset-up", + "sunset": "mdi:weather-sunset", + "fire": "mdi:fire", + "prism": "mdi:triangle-outline", + "opal": "mdi:diamond-stone", + "underwater": "mdi:waves", + "cosmos": "mdi:star-shooting", + "sunbeam": "mdi:spotlight-beam", + "enchant": "mdi:magic-staff" + } + } + } + } + } + }, "services": { "hue_activate_scene": { "service": "mdi:palette" diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 2f7f2e55561..3326dd1043f 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -11,7 +11,7 @@ } }, "manual": { - "title": "Manual configure a Hue bridge", + "title": "Manually configure a Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" }, @@ -46,8 +46,8 @@ "button_2": "Second button", "button_3": "Third button", "button_4": "Fourth button", - "double_buttons_1_3": "First and Third buttons", - "double_buttons_2_4": "Second and Fourth buttons", + "double_buttons_1_3": "First and third button", + "double_buttons_2_4": "Second and fourth button", "dim_down": "Dim down", "dim_up": "Dim up", "turn_off": "[%key:common::action::turn_off%]", @@ -102,6 +102,28 @@ } } }, + "light": { + "hue_light": { + "state_attributes": { + "effect": { + "state": { + "candle": "Candle", + "sparkle": "Sparkle", + "glisten": "Glisten", + "sunrise": "Sunrise", + "sunset": "Sunset", + "fire": "Fire", + "prism": "Prism", + "opal": "Opal", + "underwater": "Underwater", + "cosmos": "Cosmos", + "sunbeam": "Sunbeam", + "enchant": "Enchant" + } + } + } + } + }, "sensor": { "zigbee_connectivity": { "name": "Zigbee connectivity", @@ -175,5 +197,11 @@ } } } + }, + "issues": { + "deprecated_effect_none": { + "title": "Light turned on with deprecated effect", + "description": "A light was turned on with the deprecated effect `None`. This has been replaced with `off`. Please update any automations, scenes, or scripts that use this effect." + } } } diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 4b00299bc9d..8eb7ec8936e 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -18,6 +18,7 @@ from homeassistant.components.light import ( ATTR_FLASH, ATTR_TRANSITION, ATTR_XY_COLOR, + EFFECT_OFF, FLASH_SHORT, ColorMode, LightEntity, @@ -28,6 +29,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util import color as color_util from ..bridge import HueBridge @@ -39,11 +41,13 @@ from .helpers import ( normalize_hue_transition, ) -EFFECT_NONE = "None" FALLBACK_MIN_KELVIN = 6500 FALLBACK_MAX_KELVIN = 2000 FALLBACK_KELVIN = 5800 # halfway +# HA 2025.4 replaced the deprecated effect "None" with HA default "off" +DEPRECATED_EFFECT_NONE = "None" + async def async_setup_entry( hass: HomeAssistant, @@ -75,7 +79,7 @@ class HueLight(HueBaseEntity, LightEntity): _fixed_color_mode: ColorMode | None = None entity_description = LightEntityDescription( - key="hue_light", has_entity_name=True, name=None + key="hue_light", translation_key="hue_light", has_entity_name=True, name=None ) def __init__( @@ -118,7 +122,7 @@ class HueLight(HueBaseEntity, LightEntity): if x != TimedEffectStatus.NO_EFFECT ] if len(self._attr_effect_list) > 0: - self._attr_effect_list.insert(0, EFFECT_NONE) + self._attr_effect_list.insert(0, EFFECT_OFF) self._attr_supported_features |= LightEntityFeature.EFFECT @property @@ -211,7 +215,7 @@ class HueLight(HueBaseEntity, LightEntity): if timed_effects := self.resource.timed_effects: if timed_effects.status != TimedEffectStatus.NO_EFFECT: return timed_effects.status.value - return EFFECT_NONE + return EFFECT_OFF async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" @@ -233,12 +237,29 @@ class HueLight(HueBaseEntity, LightEntity): self._color_temp_active = color_temp is not None flash = kwargs.get(ATTR_FLASH) effect = effect_str = kwargs.get(ATTR_EFFECT) - if effect_str in (EFFECT_NONE, EFFECT_NONE.lower()): - # ignore effect if set to "None" and we have no effect active - # the special effect "None" is only used to stop an active effect + if effect_str == DEPRECATED_EFFECT_NONE: + # deprecated effect "None" is now "off" + effect_str = EFFECT_OFF + async_create_issue( + self.hass, + DOMAIN, + "deprecated_effect_none", + breaks_in_ha_version="2025.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_effect_none", + ) + self.logger.warning( + "Detected deprecated effect 'None' in %s, use 'off' instead. " + "This will stop working in HA 2025.10", + self.entity_id, + ) + if effect_str == EFFECT_OFF: + # ignore effect if set to "off" and we have no effect active + # the special effect "off" is only used to stop an active effect # but sending it while no effect is active can actually result in issues # https://github.com/home-assistant/core/issues/122165 - effect = None if self.effect == EFFECT_NONE else EffectStatus.NO_EFFECT + effect = None if self.effect == EFFECT_OFF else EffectStatus.NO_EFFECT elif effect_str is not None: # work out if we got a regular effect or timed effect effect = EffectStatus(effect_str) diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 436f7df8312..361636eadc6 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -62,15 +62,15 @@ "mode": { "name": "Mode", "state": { - "normal": "Normal", - "eco": "Eco", - "away": "Away", + "normal": "[%key:common::state::normal%]", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", + "auto": "Auto", + "baby": "Baby", "boost": "Boost", "comfort": "Comfort", - "home": "[%key:common::state::home%]", - "sleep": "Sleep", - "auto": "Auto", - "baby": "Baby" + "eco": "Eco", + "sleep": "Sleep" } } } diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py index 7efed529453..31ca5eef0cd 100644 --- a/homeassistant/components/husqvarna_automower/config_flow.py +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -54,7 +54,8 @@ class HusqvarnaConfigFlowHandler( automower_api = AutomowerSession(AsyncConfigFlowAuth(websession, token), tz) try: status_data = await automower_api.get_status() - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") if status_data == {}: return self.async_abort(reason="no_mower_connected") diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 45d4df95a04..7f728148be3 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2025.3.1"] + "requirements": ["aioautomower==2025.3.2"] } diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index cdcf4b45a2d..9ed00113d4b 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -44,7 +44,7 @@ async def async_set_work_area_cutting_height( ) -> None: """Set cutting height for work area.""" await coordinator.api.commands.workarea_settings( - mower_id, int(cheight), work_area_id + mower_id, work_area_id, cutting_height=int(cheight) ) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 2e1d4041e5a..d7a83c82185 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -227,12 +227,16 @@ def _get_work_area_names(data: MowerAttributes) -> list[str]: @callback def _get_current_work_area_name(data: MowerAttributes) -> str: """Return the name of the current work area.""" - if data.mower.work_area_id is None: - return STATE_NO_WORK_AREA_ACTIVE if TYPE_CHECKING: # Sensor does not get created if values are None assert data.work_areas is not None - return data.work_areas[data.mower.work_area_id].name + if ( + data.mower.work_area_id is not None + and data.mower.work_area_id in data.work_areas + ): + return data.work_areas[data.mower.work_area_id].name + + return STATE_NO_WORK_AREA_ACTIVE @callback @@ -295,6 +299,18 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None, value_fn=attrgetter("statistics.cutting_blade_usage_time"), ), + AutomowerSensorEntityDescription( + key="downtime", + translation_key="downtime", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfTime.HOURS, + exists_fn=lambda data: data.statistics.downtime is not None, + value_fn=attrgetter("statistics.downtime"), + ), AutomowerSensorEntityDescription( key="total_charging_time", translation_key="total_charging_time", @@ -367,6 +383,18 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( exists_fn=lambda data: data.statistics.total_drive_distance is not None, value_fn=attrgetter("statistics.total_drive_distance"), ), + AutomowerSensorEntityDescription( + key="uptime", + translation_key="uptime", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfTime.HOURS, + exists_fn=lambda data: data.statistics.uptime is not None, + value_fn=attrgetter("statistics.uptime"), + ), AutomowerSensorEntityDescription( key="next_start_timestamp", translation_key="next_start_timestamp", diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 9bd0bb06b3e..35ce342867f 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -221,6 +221,9 @@ "cutting_blade_usage_time": { "name": "Cutting blade usage time" }, + "downtime": { + "name": "Downtime" + }, "restricted_reason": { "name": "Restricted reason", "state": { @@ -263,6 +266,9 @@ "demo": "Demo" } }, + "uptime": { + "name": "Uptime" + }, "work_area": { "name": "Work area", "state": { diff --git a/homeassistant/components/iaqualink/entity.py b/homeassistant/components/iaqualink/entity.py index 437611e5a5f..d0176ed8bfe 100644 --- a/homeassistant/components/iaqualink/entity.py +++ b/homeassistant/components/iaqualink/entity.py @@ -32,7 +32,6 @@ class AqualinkEntity(Entity): manufacturer=dev.manufacturer, model=dev.model, name=dev.label, - via_device=(DOMAIN, dev.system.serial), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index 2531632075c..7e05bd72f0b 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/iaqualink", "iot_class": "cloud_polling", "loggers": ["iaqualink"], - "requirements": ["iaqualink==0.5.0", "h2==4.1.0"], + "requirements": ["iaqualink==0.5.3", "h2==4.1.0"], "single_config_entry": true } diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json index 7486973638b..ff0cb5b8ae6 100644 --- a/homeassistant/components/idasen_desk/strings.json +++ b/homeassistant/components/idasen_desk/strings.json @@ -7,7 +7,7 @@ "address": "Device" }, "data_description": { - "address": "The bluetooth device for the desk." + "address": "The Bluetooth device for the desk." } } }, @@ -26,10 +26,10 @@ "entity": { "button": { "connect": { - "name": "Connect" + "name": "[%key:common::action::connect%]" }, "disconnect": { - "name": "Disconnect" + "name": "[%key:common::action::disconnect%]" } }, "sensor": { diff --git a/homeassistant/components/imgw_pib/config_flow.py b/homeassistant/components/imgw_pib/config_flow.py index 558528fcbef..805bfa2ccb3 100644 --- a/homeassistant/components/imgw_pib/config_flow.py +++ b/homeassistant/components/imgw_pib/config_flow.py @@ -50,7 +50,7 @@ class ImgwPibFlowHandler(ConfigFlow, domain=DOMAIN): hydrological_data = await imgwpib.get_hydrological_data() except (ClientError, TimeoutError, ApiError): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 0ecc1b4b7d0..e2d6e2bf584 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", - "requirements": ["imgw_pib==1.0.9"] + "quality_scale": "silver", + "requirements": ["imgw_pib==1.0.10"] } diff --git a/homeassistant/components/imgw_pib/quality_scale.yaml b/homeassistant/components/imgw_pib/quality_scale.yaml new file mode 100644 index 00000000000..6634c915255 --- /dev/null +++ b/homeassistant/components/imgw_pib/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register services. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not register services. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration does not register services. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: No authentication required. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: The integration is a cloud service and thus does not support discovery. + discovery: + status: exempt + comment: The integration is a cloud service and thus does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: This is a service, which doesn't integrate with any devices. + docs-supported-functions: todo + docs-troubleshooting: + status: exempt + comment: No known issues that could be resolved by the user. + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration has a fixed single service. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: This integration does not have any entities that should disabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: Only parameter that could be changed station_id would force a new config entry. + repair-issues: + status: exempt + comment: This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: This integration has a fixed single service. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 33b82bbb43b..7871006b2ae 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -24,7 +24,8 @@ from .const import DOMAIN from .coordinator import ImgwPibConfigEntry, ImgwPibDataUpdateCoordinator from .entity import ImgwPibEntity -PARALLEL_UPDATES = 1 +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 89be0661c6f..33cd3cb3917 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "station_id": "Hydrological station" + }, + "data_description": { + "station_id": "Select a hydrological station from the list." } } }, diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index 875bc25bd2f..027c3ad4691 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from incomfortclient import InvalidGateway, InvalidHeaterList @@ -31,6 +32,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import InComfortConfigEntry, async_connect_gateway +_LOGGER = logging.getLogger(__name__) TITLE = "Intergas InComfort/Intouch Lan2RF gateway" CONFIG_SCHEMA = vol.Schema( @@ -88,7 +90,8 @@ async def async_try_connect_gateway( return {"base": "no_heaters"} except TimeoutError: return {"base": "timeout_error"} - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return {"base": "unknown"} return None diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 73ba88078a8..6a07849b01d 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -10,8 +10,8 @@ }, "data_description": { "host": "Hostname or IP-address of the Intergas gateway.", - "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." + "username": "The username to log in to the gateway. This is `admin` in most cases.", + "password": "The password to log in to the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." } }, "dhcp_auth": { @@ -22,8 +22,8 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices." + "username": "[%key:component::incomfort::config::step::user::data_description::username%]", + "password": "[%key:component::incomfort::config::step::user::data_description::password%]" } }, "dhcp_confirm": { @@ -118,7 +118,7 @@ "tapwater_int": "Tap water internal", "sensor_test": "Sensor test", "central_heating": "Central heating", - "standby": "Stand-by", + "standby": "[%key:common::state::standby%]", "postrun_boyler": "Post run boiler", "service": "Service", "tapwater": "Tap water", diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 9dd058e841a..467fa2445e8 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime, timedelta import logging from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate @@ -9,13 +10,16 @@ from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate from homeassistant.components.bluetooth import ( BluetoothScanningMode, BluetoothServiceInfo, + BluetoothServiceInfoBleak, + async_ble_device_from_address, ) -from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothProcessorCoordinator, +from homeassistant.components.bluetooth.active_update_processor import ( + ActiveBluetoothProcessorCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_interval from .const import CONF_DEVICE_TYPE, DOMAIN @@ -23,34 +27,87 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +FALLBACK_POLL_INTERVAL = timedelta(seconds=180) + + +class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): + """Coordinator for INKBIRD Bluetooth devices.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + data: INKBIRDBluetoothDeviceData, + ) -> None: + """Initialize the INKBIRD Bluetooth processor coordinator.""" + self._data = data + self._entry = entry + address = entry.unique_id + assert address is not None + entry.async_on_unload( + async_track_time_interval( + hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL + ) + ) + super().__init__( + hass=hass, + logger=_LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=self._async_on_update, + needs_poll_method=self._async_needs_poll, + poll_method=self._async_poll_data, + ) + + async def _async_poll_data( + self, last_service_info: BluetoothServiceInfoBleak + ) -> SensorUpdate: + """Poll the device.""" + return await self._data.async_poll(last_service_info.device) + + @callback + def _async_needs_poll( + self, service_info: BluetoothServiceInfoBleak, last_poll: float | None + ) -> bool: + return ( + not self.hass.is_stopping + and self._data.poll_needed(service_info, last_poll) + and bool( + async_ble_device_from_address( + self.hass, service_info.device.address, connectable=True + ) + ) + ) + + @callback + def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate: + """Handle update callback from the passive BLE processor.""" + update = self._data.update(service_info) + if ( + self._entry.data.get(CONF_DEVICE_TYPE) is None + and self._data.device_type is not None + ): + device_type_str = str(self._data.device_type) + self.hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str}, + ) + return update + + @callback + def _async_schedule_poll(self, _: datetime) -> None: + """Schedule a poll of the device.""" + if self._last_service_info and self._async_needs_poll( + self._last_service_info, self._last_poll + ): + self._debounced_poll.async_schedule_call() + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" - address = entry.unique_id - assert address is not None device_type: str | None = entry.data.get(CONF_DEVICE_TYPE) data = INKBIRDBluetoothDeviceData(device_type) - - @callback - def _async_on_update(service_info: BluetoothServiceInfo) -> SensorUpdate: - """Handle update callback from the passive BLE processor.""" - nonlocal device_type - update = data.update(service_info) - if device_type is None and data.device_type is not None: - device_type_str = str(data.device_type) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_DEVICE_TYPE: device_type_str} - ) - device_type = device_type_str - return update - - coordinator = PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=_async_on_update, - ) + coordinator = INKBIRDActiveBluetoothProcessorCoordinator(hass, entry, data) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # only start after all platforms have had a chance to subscribe diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index aaa9c4b3473..ea980babf7e 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -40,5 +40,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.9.0"] + "requirements": ["inkbird-ble==0.10.1"] } diff --git a/homeassistant/components/iometer/__init__.py b/homeassistant/components/iometer/__init__.py index bbf046e70e9..feb7ce9b8cf 100644 --- a/homeassistant/components/iometer/__init__.py +++ b/homeassistant/components/iometer/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import IOmeterConfigEntry, IOMeterCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: IOmeterConfigEntry) -> bool: diff --git a/homeassistant/components/iometer/binary_sensor.py b/homeassistant/components/iometer/binary_sensor.py new file mode 100644 index 00000000000..f443c4ae94a --- /dev/null +++ b/homeassistant/components/iometer/binary_sensor.py @@ -0,0 +1,87 @@ +"""IOmeter binary sensor.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import IOMeterCoordinator, IOmeterData +from .entity import IOmeterEntity + + +@dataclass(frozen=True, kw_only=True) +class IOmeterBinarySensorDescription(BinarySensorEntityDescription): + """Describes Iometer binary sensor entity.""" + + value_fn: Callable[[IOmeterData], bool | None] + + +SENSOR_TYPES: list[IOmeterBinarySensorDescription] = [ + IOmeterBinarySensorDescription( + key="connection_status", + translation_key="connection_status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_registry_enabled_default=False, + value_fn=lambda data: ( + data.status.device.core.connection_status == "connected" + if data.status.device.core.connection_status is not None + else None + ), + ), + IOmeterBinarySensorDescription( + key="attachment_status", + translation_key="attachment_status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_registry_enabled_default=False, + value_fn=lambda data: ( + data.status.device.core.attachment_status == "attached" + if data.status.device.core.attachment_status is not None + else None + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sensors.""" + coordinator: IOMeterCoordinator = config_entry.runtime_data + + async_add_entities( + IOmeterBinarySensor( + coordinator=coordinator, + description=description, + ) + for description in SENSOR_TYPES + ) + + +class IOmeterBinarySensor(IOmeterEntity, BinarySensorEntity): + """Defines a IOmeter binary sensor.""" + + entity_description: IOmeterBinarySensorDescription + + def __init__( + self, + coordinator: IOMeterCoordinator, + description: IOmeterBinarySensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.identifier}_{description.key}" + + @property + def is_on(self) -> bool | None: + """Return the binary sensor state.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/iometer/strings.json b/homeassistant/components/iometer/strings.json index 31deb16aa9c..b3878dd1b53 100644 --- a/homeassistant/components/iometer/strings.json +++ b/homeassistant/components/iometer/strings.json @@ -60,6 +60,14 @@ "wifi_rssi": { "name": "Signal strength Wi-Fi" } + }, + "binary_sensor": { + "connection_status": { + "name": "Core/Bridge connection status" + }, + "attachment_status": { + "name": "Core attachment status" + } } } } diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json index ac879ef0ab3..b4c092c8ae3 100644 --- a/homeassistant/components/ipp/strings.json +++ b/homeassistant/components/ipp/strings.json @@ -38,7 +38,7 @@ "state": { "printing": "Printing", "idle": "[%key:common::state::idle%]", - "stopped": "Stopped" + "stopped": "[%key:common::state::stopped%]" } }, "uptime": { diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index ddae9a3020f..629f7c32c9b 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -123,7 +123,7 @@ "state": { "off": "[%key:common::state::off%]", "slow": "[%key:component::iron_os::common::slow%]", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "fast": "[%key:component::iron_os::common::fast%]" } }, diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py index ee54e502c26..0a8ed6e9ddb 100644 --- a/homeassistant/components/ista_ecotrend/sensor.py +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -8,6 +8,7 @@ import datetime from enum import StrEnum import logging +from homeassistant.components.recorder.models import StatisticMeanType from homeassistant.components.recorder.models.statistics import ( StatisticData, StatisticMetaData, @@ -270,7 +271,7 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity): ] metadata: StatisticMetaData = { - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": f"{self.device_entry.name} {self.name}", "source": DOMAIN, diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 738c7e2d5ad..1e227b08206 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -138,7 +138,7 @@ async def async_setup_entry( for vtype, _, vid in isy.variables.children: numbers.append(isy.variables[vtype][vid]) if ( - isy.conf[CONFIG_NETWORKING] or isy.conf[CONFIG_PORTAL] + isy.conf[CONFIG_NETWORKING] or isy.conf.get(CONFIG_PORTAL) ) and isy.networking.nobjs: isy_data.devices[CONF_NETWORK] = _create_service_device_info( isy, name=CONFIG_NETWORKING, unique_id=CONF_NETWORK @@ -227,9 +227,9 @@ async def async_unload_entry( """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - isy_data = hass.data[DOMAIN][entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - isy: ISY = isy_data.root + isy = isy_data.root _LOGGER.debug("ISY Stopping Event Stream and automatic updates") isy.websocket.stop() diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 1da727fdee8..d170854396c 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -181,6 +181,7 @@ class ISYProgramEntity(ISYEntity): _actions: Program _status: Program + _node: Program def __init__(self, name: str, status: Program, actions: Program = None) -> None: """Initialize the ISY program-based entity.""" diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 3aa81027b4f..5cd3bb73a89 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.1.14"], + "requirements": ["pyisy==3.4.0"], "ssdp": [ { "manufacturer": "Universal Devices Inc.", diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 6546aec6efa..24cfa9aefb1 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -21,6 +21,7 @@ from homeassistant.helpers.service import entity_service_call from homeassistant.helpers.typing import VolDictType from .const import _LOGGER, DOMAIN +from .models import IsyData # Common Services for All Platforms: SERVICE_SEND_PROGRAM_COMMAND = "send_program_command" @@ -149,7 +150,7 @@ def async_setup_services(hass: HomeAssistant) -> None: isy_name = service.data.get(CONF_ISY) for config_entry_id in hass.data[DOMAIN]: - isy_data = hass.data[DOMAIN][config_entry_id] + isy_data: IsyData = hass.data[DOMAIN][config_entry_id] isy = isy_data.root if isy_name and isy_name != isy.conf["name"]: continue diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 8872226daba..6594c030f08 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -90,7 +90,7 @@ }, "get_zwave_parameter": { "name": "Get Z-Wave Parameter", - "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "Parameter", @@ -100,7 +100,7 @@ }, "set_zwave_parameter": { "name": "Set Z-Wave parameter", - "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "[%key:component::isy994::services::get_zwave_parameter::fields::parameter::name%]", diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 946feddcd10..d5c8a23cbea 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -157,7 +157,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity): device_info=device_info, ) self._attr_name = description.name # Override super - self._change_handler: EventListener = None + self._change_handler: EventListener | None = None # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py index ab5d5e7d7f8..91fe0885e4c 100644 --- a/homeassistant/components/jellyfin/client_wrapper.py +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -97,16 +97,27 @@ def get_artwork_url( client: JellyfinClient, item: dict[str, Any], max_width: int = 600 ) -> str | None: """Find a suitable thumbnail for an item.""" - artwork_id: str = item["Id"] - artwork_type = "Primary" + artwork_id: str | None = None + artwork_type: str | None = None parent_backdrop_id: str | None = item.get("ParentBackdropItemId") - if "Backdrop" in item[ITEM_KEY_IMAGE_TAGS]: + if "AlbumPrimaryImageTag" in item: + # jellyfin_apiclient_python doesn't support passing a specific tag to `.artwork`, + # so we don't use the actual value of AlbumPrimaryImageTag. + # However, its mere presence tells us that the album does have primary artwork, + # and the resulting URL will pull the primary album art even if the tag is not specified. + artwork_type = "Primary" + artwork_id = item["AlbumId"] + elif "Backdrop" in item[ITEM_KEY_IMAGE_TAGS]: artwork_type = "Backdrop" + artwork_id = item["Id"] elif parent_backdrop_id: artwork_type = "Backdrop" artwork_id = parent_backdrop_id - elif "Primary" not in item[ITEM_KEY_IMAGE_TAGS]: + elif "Primary" in item[ITEM_KEY_IMAGE_TAGS]: + artwork_type = "Primary" + artwork_id = item["Id"] + else: return None return str(client.jellyfin.artwork(artwork_id, artwork_type, max_width)) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 9f7ec6ba976..47d60d74938 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -16,7 +16,8 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CANDLE_LIGHT_MINUTES, @@ -26,11 +27,21 @@ from .const import ( DEFAULT_DIASPORA, DEFAULT_HAVDALAH_OFFSET_MINUTES, DEFAULT_LANGUAGE, + DOMAIN, ) from .entity import JewishCalendarConfigEntry, JewishCalendarData +from .service import async_setup_services _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Jewish Calendar service.""" + async_setup_services(hass) + + return True async def async_setup_entry( @@ -113,8 +124,8 @@ async def async_migrate_entry( "first_stars": "tset_hakohavim_tsom", "three_stars": "tset_hakohavim_shabbat", } - new_keys = tuple(key_translations.values()) - if not entity_entry.unique_id.endswith(new_keys): + old_keys = tuple(key_translations.keys()) + if entity_entry.unique_id.endswith(old_keys): old_key = entity_entry.unique_id.split("-")[1] new_unique_id = f"{config_entry.entry_id}-{key_translations[old_key]}" return {"new_unique_id": new_unique_id} diff --git a/homeassistant/components/jewish_calendar/const.py b/homeassistant/components/jewish_calendar/const.py index 4af76a8927b..0d5455fcd86 100644 --- a/homeassistant/components/jewish_calendar/const.py +++ b/homeassistant/components/jewish_calendar/const.py @@ -2,6 +2,9 @@ DOMAIN = "jewish_calendar" +ATTR_DATE = "date" +ATTR_NUSACH = "nusach" + CONF_DIASPORA = "diaspora" CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" @@ -11,3 +14,5 @@ DEFAULT_CANDLE_LIGHT = 18 DEFAULT_DIASPORA = False DEFAULT_HAVDALAH_OFFSET_MINUTES = 0 DEFAULT_LANGUAGE = "english" + +SERVICE_COUNT_OMER = "count_omer" diff --git a/homeassistant/components/jewish_calendar/icons.json b/homeassistant/components/jewish_calendar/icons.json new file mode 100644 index 00000000000..24b922df7a2 --- /dev/null +++ b/homeassistant/components/jewish_calendar/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "count_omer": { + "service": "mdi:counter" + } + } +} diff --git a/homeassistant/components/jewish_calendar/service.py b/homeassistant/components/jewish_calendar/service.py new file mode 100644 index 00000000000..7c3c7a21f1c --- /dev/null +++ b/homeassistant/components/jewish_calendar/service.py @@ -0,0 +1,63 @@ +"""Services for Jewish Calendar.""" + +import datetime +from typing import cast + +from hdate import HebrewDate +from hdate.omer import Nusach, Omer +from hdate.translator import Language +import voluptuous as vol + +from homeassistant.const import CONF_LANGUAGE +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import LanguageSelector, LanguageSelectorConfig + +from .const import ATTR_DATE, ATTR_NUSACH, DOMAIN, SERVICE_COUNT_OMER + +SUPPORTED_LANGUAGES = {"en": "english", "fr": "french", "he": "hebrew"} +OMER_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DATE, default=datetime.date.today): cv.date, + vol.Required(ATTR_NUSACH, default="sfarad"): vol.In( + [nusach.name.lower() for nusach in Nusach] + ), + vol.Required(CONF_LANGUAGE, default="he"): LanguageSelector( + LanguageSelectorConfig(languages=list(SUPPORTED_LANGUAGES.keys())) + ), + } +) + + +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the Jewish Calendar services.""" + + async def get_omer_count(call: ServiceCall) -> ServiceResponse: + """Return the Omer blessing for a given date.""" + hebrew_date = HebrewDate.from_gdate(call.data["date"]) + nusach = Nusach[call.data["nusach"].upper()] + + # Currently Omer only supports Hebrew, English, and French and requires + # the full language name + language = cast(Language, SUPPORTED_LANGUAGES[call.data[CONF_LANGUAGE]]) + + omer = Omer(date=hebrew_date, nusach=nusach, language=language) + return { + "message": str(omer.count_str()), + "weeks": omer.week, + "days": omer.day, + "total_days": omer.total_days, + } + + hass.services.async_register( + DOMAIN, + SERVICE_COUNT_OMER, + get_omer_count, + schema=OMER_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/jewish_calendar/services.yaml b/homeassistant/components/jewish_calendar/services.yaml new file mode 100644 index 00000000000..894fa30fee3 --- /dev/null +++ b/homeassistant/components/jewish_calendar/services.yaml @@ -0,0 +1,29 @@ +count_omer: + fields: + date: + required: true + example: "2025-04-14" + selector: + date: + nusach: + required: true + example: "sfarad" + default: "sfarad" + selector: + select: + translation_key: "nusach" + options: + - "sfarad" + - "ashkenaz" + - "adot_mizrah" + - "italian" + language: + required: true + default: "he" + example: "he" + selector: + language: + languages: + - "en" + - "he" + - "fr" diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json index 1b7b86c0056..933d77d2188 100644 --- a/homeassistant/components/jewish_calendar/strings.json +++ b/homeassistant/components/jewish_calendar/strings.json @@ -3,9 +3,9 @@ "sensor": { "hebrew_date": { "state_attributes": { - "hebrew_year": { "name": "Hebrew Year" }, - "hebrew_month_name": { "name": "Hebrew Month Name" }, - "hebrew_day": { "name": "Hebrew Day" } + "hebrew_year": { "name": "Hebrew year" }, + "hebrew_month_name": { "name": "Hebrew month name" }, + "hebrew_day": { "name": "Hebrew day" } } } } @@ -16,10 +16,10 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "diaspora": "Outside of Israel?", - "language": "Language for Holidays and Dates", + "language": "Language for holidays and dates", "location": "[%key:common::config_flow::data::location%]", "elevation": "[%key:common::config_flow::data::elevation%]", - "time_zone": "Time Zone" + "time_zone": "Time zone" }, "data_description": { "time_zone": "If you specify a location, make sure to specify the time zone for correct calendar times calculations" @@ -36,7 +36,7 @@ "init": { "title": "Configure options for Jewish Calendar", "data": { - "candle_lighting_minutes_before_sunset": "Minutes before sunset for candle lighthing", + "candle_lighting_minutes_before_sunset": "Minutes before sunset for candle lighting", "havdalah_minutes_after_sunset": "Minutes after sunset for Havdalah" }, "data_description": { @@ -45,5 +45,35 @@ } } } + }, + "selector": { + "nusach": { + "options": { + "sfarad": "Sfarad", + "ashkenaz": "Ashkenaz", + "adot_mizrah": "Adot Mizrah", + "italian": "Italian" + } + } + }, + "services": { + "count_omer": { + "name": "Count the Omer", + "description": "Returns the phrase for counting the Omer on a given date.", + "fields": { + "date": { + "name": "Date", + "description": "Date to count the Omer for." + }, + "nusach": { + "name": "Nusach", + "description": "Nusach to count the Omer in." + }, + "language": { + "name": "[%key:common::config_flow::data::language%]", + "description": "Language to count the Omer in." + } + } + } } } diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index de8e521f0e8..2f876ca855d 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -12,14 +12,24 @@ from random import random import voluptuous as vol from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, async_import_statistics, get_last_statistics, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume +from homeassistant.const import ( + DEGREE, + Platform, + UnitOfEnergy, + UnitOfTemperature, + UnitOfVolume, +) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -72,6 +82,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set the config entry up.""" + if "recorder" in hass.config.components: + # Insert stats for mean_type_changed issue + await _insert_wrong_wind_direction_statistics(hass) + # Set up demo platforms with config entry await hass.config_entries.async_forward_entry_setups( entry, COMPONENTS_WITH_DEMO_PLATFORM @@ -233,7 +247,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Outdoor temperature", "statistic_id": f"{DOMAIN}:temperature_outdoor", "unit_of_measurement": UnitOfTemperature.CELSIUS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -246,7 +260,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Energy consumption 1", "statistic_id": f"{DOMAIN}:energy_consumption_kwh", "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 1) @@ -258,7 +272,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Energy consumption 2", "statistic_id": f"{DOMAIN}:energy_consumption_mwh", "unit_of_measurement": UnitOfEnergy.MEGA_WATT_HOUR, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics( @@ -272,7 +286,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Gas consumption 1", "statistic_id": f"{DOMAIN}:gas_consumption_m3", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics( @@ -286,7 +300,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Gas consumption 2", "statistic_id": f"{DOMAIN}:gas_consumption_ft3", "unit_of_measurement": UnitOfVolume.CUBIC_FEET, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 15) @@ -298,7 +312,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_1", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -310,7 +324,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_2", "unit_of_measurement": "cats", - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -322,7 +336,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_3", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -334,8 +348,28 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_4", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) async_import_statistics(hass, metadata, statistics) + + +async def _insert_wrong_wind_direction_statistics(hass: HomeAssistant) -> None: + """Insert some fake wind direction statistics.""" + now = dt_util.now() + yesterday = now - datetime.timedelta(days=1) + yesterday_midnight = yesterday.replace(hour=0, minute=0, second=0, microsecond=0) + today_midnight = yesterday_midnight + datetime.timedelta(days=1) + + # Add some statistics required to raise the mean_type_changed issue later + metadata: StatisticMetaData = { + "source": RECORDER_DOMAIN, + "name": None, + "statistic_id": "sensor.statistics_issues_issue_5", + "unit_of_measurement": DEGREE, + "mean_type": StatisticMeanType.ARITHMETIC, + "has_sum": False, + } + statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 0, 360) + async_import_statistics(hass, metadata, statistics) diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index e1ffe334038..aa722d27944 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.config_entries import ( SubentryFlowResult, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from . import DOMAIN @@ -80,30 +81,30 @@ class OptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(data=self.config_entry.options | user_input) - return self.async_show_form( - step_id="options_1", - data_schema=vol.Schema( - { - vol.Required("section_1"): data_entry_flow.section( - vol.Schema( - { - vol.Optional( - CONF_BOOLEAN, - default=self.config_entry.options.get( - CONF_BOOLEAN, False - ), - ): bool, - vol.Optional( - CONF_INT, - default=self.config_entry.options.get(CONF_INT, 10), - ): int, - } - ), - {"collapsed": False}, + data_schema = vol.Schema( + { + vol.Required("section_1"): data_entry_flow.section( + vol.Schema( + { + vol.Optional( + CONF_BOOLEAN, + default=self.config_entry.options.get( + CONF_BOOLEAN, False + ), + ): bool, + vol.Optional(CONF_INT): cv.positive_int, + } ), - } - ), + {"collapsed": False}, + ), + } ) + self.add_suggested_values_to_schema( + data_schema, + {"section_1": {"int": self.config_entry.options.get(CONF_INT, 10)}}, + ) + + return self.async_show_form(step_id="options_1", data_schema=data_schema) class SubentryFlowHandler(ConfigSubentryFlow): @@ -146,7 +147,7 @@ class SubentryFlowHandler(ConfigSubentryFlow): if user_input is not None: title = user_input.pop("name") return self.async_update_and_abort( - self._get_reconfigure_entry(), + self._get_entry(), self._get_reconfigure_subentry(), data=user_input, title=title, diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 19d1b31aeab..04cb833f0df 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPower +from homeassistant.const import DEGREE, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -87,6 +87,16 @@ async def async_setup_entry( state_class=None, unit_of_measurement=UnitOfPower.WATT, ), + DemoSensor( + device_unique_id="statistics_issues", + unique_id="statistics_issue_5", + device_name="Statistics issues", + entity_name="Issue 5", + state=100, + device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + unit_of_measurement=DEGREE, + ), ] ) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 10730d87ed1..b13667a65b0 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -316,10 +316,10 @@ "name": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::name%]", "state": { "auto": "Auto", + "building_protection": "Building protection", "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", - "standby": "Standby", "economy": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "building_protection": "Building protection" + "standby": "[%key:common::state::standby%]" } } } diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json index e1a6863a199..df92e014f12 100644 --- a/homeassistant/components/konnected/strings.json +++ b/homeassistant/components/konnected/strings.json @@ -2,19 +2,19 @@ "config": { "step": { "import_confirm": { - "title": "Import Konnected Device", - "description": "A Konnected Alarm Panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry." + "title": "Import Konnected device", + "description": "A Konnected alarm panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry." }, "user": { - "description": "Please enter the host information for your Konnected Panel.", + "description": "Please enter the host information for your Konnected panel.", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]" } }, "confirm": { - "title": "Konnected Device Ready", - "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings." + "title": "Konnected device ready", + "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected alarm panel settings." } }, "error": { @@ -45,8 +45,8 @@ } }, "options_io_ext": { - "title": "Configure Extended I/O", - "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.", + "title": "Configure extended I/O", + "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.", "data": { "8": "Zone 8", "9": "Zone 9", @@ -59,25 +59,25 @@ } }, "options_binary": { - "title": "Configure Binary Sensor", + "title": "Configure binary sensor", "description": "{zone} options", "data": { - "type": "Binary Sensor Type", + "type": "Binary sensor type", "name": "[%key:common::config_flow::data::name%]", "inverse": "Invert the open/close state" } }, "options_digital": { - "title": "Configure Digital Sensor", + "title": "Configure digital sensor", "description": "[%key:component::konnected::options::step::options_binary::description%]", "data": { - "type": "Sensor Type", + "type": "Sensor type", "name": "[%key:common::config_flow::data::name%]", - "poll_interval": "Poll Interval (minutes)" + "poll_interval": "Poll interval (minutes)" } }, "options_switch": { - "title": "Configure Switchable Output", + "title": "Configure switchable output", "description": "{zone} options: state {state}", "data": { "name": "[%key:common::config_flow::data::name%]", @@ -89,18 +89,18 @@ } }, "options_misc": { - "title": "Configure Misc", + "title": "Configure misc", "description": "Please select the desired behavior for your panel", "data": { "discovery": "Respond to discovery requests on your network", "blink": "Blink panel LED on when sending state change", - "override_api_host": "Override default Home Assistant API host panel URL", - "api_host": "Override API host URL" + "override_api_host": "Override default Home Assistant API host URL", + "api_host": "Custom API host URL" } } }, "error": { - "bad_host": "Invalid Override API host URL" + "bad_host": "Invalid custom API host URL" }, "abort": { "not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]" diff --git a/homeassistant/components/kulersky/manifest.json b/homeassistant/components/kulersky/manifest.json index e0d9ec4fe36..49c4d4c1847 100644 --- a/homeassistant/components/kulersky/manifest.json +++ b/homeassistant/components/kulersky/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/kulersky", "iot_class": "local_polling", "loggers": ["bleak", "pykulersky"], - "requirements": ["pykulersky==0.5.2"] + "requirements": ["pykulersky==0.5.8"] } diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 667fcbb8dcc..dde8dfd54a2 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -106,6 +106,7 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=DEGREE, suggested_display_precision=2, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), "WetDry": LaCrosseSensorEntityDescription( key="WetDry", diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 04853b8d0ca..f087856dbed 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -146,7 +146,7 @@ "prebrew_infusion_select": { "name": "Prebrew/-infusion mode", "state": { - "disabled": "Disabled", + "disabled": "[%key:common::state::disabled%]", "prebrew": "Prebrew", "prebrew_enabled": "Prebrew", "preinfusion": "Preinfusion" diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 0e1f680dd63..ca40aebd0d4 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from pylast import LastFMNetwork, PyLastError, User, WSError @@ -32,6 +33,8 @@ CONFIG_SCHEMA: vol.Schema = vol.Schema( } ) +_LOGGER = logging.getLogger(__name__) + def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]: """Get and validate lastFM User.""" @@ -49,7 +52,8 @@ def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]: errors["base"] = "invalid_auth" else: errors["base"] = "unknown" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" return user, errors diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 7783df8679a..0c78ea6637a 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE, @@ -49,6 +50,7 @@ DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.METERPERSECOND: SensorDeviceClass.SPEED, pypck.lcn_defs.VarUnit.VOLT: SensorDeviceClass.VOLTAGE, pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT, + pypck.lcn_defs.VarUnit.PPM: SensorDeviceClass.CO2, } UNIT_OF_MEASUREMENT_MAPPING = { @@ -60,6 +62,7 @@ UNIT_OF_MEASUREMENT_MAPPING = { pypck.lcn_defs.VarUnit.METERPERSECOND: UnitOfSpeed.METERS_PER_SECOND, pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT, pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE, + pypck.lcn_defs.VarUnit.PPM: CONCENTRATION_PARTS_PER_MILLION, } diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 1896f2109a7..3d8f8793e25 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.27.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 14f2f228e13..2facda734d5 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from led_ble import LEDBLE @@ -83,7 +83,7 @@ class LEDBLEEntity(CoordinatorEntity[DataUpdateCoordinator[None]], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) + brightness = cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness)) if effect := kwargs.get(ATTR_EFFECT): await self._async_set_effect(effect, brightness) return diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 270495c8770..62ad21eb99a 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.26.1", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.6"] } diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index eb0203e0661..eb223b4758b 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -87,11 +87,11 @@ "state": { "available": "Available", "charging": "[%key:common::state::charging%]", - "connected": "Connected", + "connected": "[%key:common::state::connected%]", "error": "Error", - "locked": "Locked", + "locked": "[%key:common::state::locked%]", "need_auth": "Waiting for authentication", - "paused": "Paused", + "paused": "[%key:common::state::paused%]", "paused_by_scheduler": "Paused by scheduler", "updating_firmware": "Updating firmware" } diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index 513cd27a7b2..9f84c422277 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -63,10 +63,12 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Add a callback to handle core config update. self.unit_system: str | None = None - self.hass.bus.async_listen( - event_type=EVENT_CORE_CONFIG_UPDATE, - listener=self._handle_update_config, - event_filter=self.async_config_update_filter, + self.config_entry.async_on_unload( + self.hass.bus.async_listen( + event_type=EVENT_CORE_CONFIG_UPDATE, + listener=self._handle_update_config, + event_filter=self.async_config_update_filter, + ) ) async def _handle_update_config(self, _: Event) -> None: diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 787b50167c1..3b0baaaaf75 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -169,6 +169,9 @@ "current_job_mode": { "default": "mdi:format-list-bulleted" }, + "current_job_mode_dehumidifier": { + "default": "mdi:format-list-bulleted" + }, "operation_mode": { "default": "mdi:gesture-tap-button" }, diff --git a/homeassistant/components/lg_thinq/number.py b/homeassistant/components/lg_thinq/number.py index 7003519e0ce..ac8991d6bb5 100644 --- a/homeassistant/components/lg_thinq/number.py +++ b/homeassistant/components/lg_thinq/number.py @@ -123,6 +123,9 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] = NUMBER_DESC[ThinQProperty.LIGHT_STATUS], NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE], ), + DeviceType.VENTILATOR: ( + TIMER_NUMBER_DESC[ThinQProperty.SLEEP_TIMER_RELATIVE_HOUR_TO_STOP], + ), } _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lg_thinq/select.py b/homeassistant/components/lg_thinq/select.py index 929fa0b1d28..3f29ee9e5c8 100644 --- a/homeassistant/components/lg_thinq/select.py +++ b/homeassistant/components/lg_thinq/select.py @@ -98,7 +98,13 @@ DEVICE_TYPE_SELECT_MAP: dict[DeviceType, tuple[SelectEntityDescription, ...]] = AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH], SELECT_DESC[ThinQProperty.CURRENT_JOB_MODE], ), - DeviceType.DEHUMIDIFIER: (AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],), + DeviceType.DEHUMIDIFIER: ( + AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH], + SelectEntityDescription( + key=ThinQProperty.CURRENT_JOB_MODE, + translation_key="current_job_mode_dehumidifier", + ), + ), DeviceType.DISH_WASHER: ( OPERATION_SELECT_DESC[ThinQProperty.DISH_WASHER_OPERATION_MODE], ), diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index e1d3779f44b..525a594f748 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -19,7 +19,7 @@ "description": "Please enter a ThinQ [PAT(Personal Access Token)]({pat_url}) created with your LG ThinQ account.", "data": { "access_token": "Personal Access Token", - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" } } } @@ -119,9 +119,9 @@ "fan_mode": { "state": { "slow": "Slow", - "low": "Low", - "mid": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "mid": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]" } @@ -303,7 +303,7 @@ "state": { "invalid": "Invalid", "weak": "Weak", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "strong": "Strong", "very_strong": "Very strong" } @@ -390,17 +390,17 @@ "temperature_state": { "name": "[%key:component::sensor::entity_component::temperature::name%]", "state": { - "high": "High", + "high": "[%key:common::state::high%]", "normal": "Good", - "low": "Low" + "low": "[%key:common::state::low%]" } }, "temperature_state_for_location": { "name": "[%key:component::lg_thinq::entity::number::target_temperature_for_location::name%]", "state": { - "high": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::high%]", + "high": "[%key:common::state::high%]", "normal": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::normal%]", - "low": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::low%]" + "low": "[%key:common::state::low%]" } }, "current_state": { @@ -607,7 +607,7 @@ "intensive_dry": "Spot", "macro": "Custom mode", "mop": "Mop", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]", "quiet_humidity": "Silent", "rapid_humidity": "Jet", @@ -626,7 +626,7 @@ "auto": "Low power", "high": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "mop": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::mop%]", - "normal": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::normal%]", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]" } @@ -653,7 +653,7 @@ "heavy": "Intensive", "delicate": "Delicate", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "rinse": "Rinse", "refresh": "Refresh", "express": "Express", @@ -781,8 +781,8 @@ "name": "Battery", "state": { "high": "Full", - "mid": "Medium", - "low": "Low", + "mid": "[%key:common::state::medium%]", + "low": "[%key:common::state::low%]", "warning": "Empty" } }, @@ -876,9 +876,9 @@ "name": "Speed", "state": { "slow": "[%key:component::lg_thinq::entity::climate::climate_air_conditioner::state_attributes::fan_mode::state::slow%]", - "low": "Low", - "mid": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "mid": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "power": "Turbo", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]", "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", @@ -928,6 +928,17 @@ "vacation": "Vacation" } }, + "current_job_mode_dehumidifier": { + "name": "[%key:component::lg_thinq::entity::sensor::current_job_mode::name%]", + "state": { + "air_clean": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::air_clean%]", + "clothes_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::clothes_dry%]", + "intensive_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::intensive_dry%]", + "quiet_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::quiet_humidity%]", + "rapid_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::rapid_humidity%]", + "smart_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::smart_humidity%]" + } + }, "operation_mode": { "name": "Operation", "state": { diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 637ba45c7d9..7b548533058 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -465,7 +465,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ): params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value) color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) - brightness = params.get(ATTR_BRIGHTNESS, light.brightness) + brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness)) params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww( color_temp, brightness, diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index df98def090e..6218c733f4c 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -1,7 +1,15 @@ { "entity_component": { "_": { - "default": "mdi:lightbulb" + "default": "mdi:lightbulb", + "state_attributes": { + "effect": { + "default": "mdi:circle-medium", + "state": { + "off": "mdi:star-off" + } + } + } } }, "services": { diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 2a1fbd11afd..c59d9e22483 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -293,11 +293,10 @@ turn_on: - light.LightEntityFeature.FLASH selector: select: + translation_key: flash options: - - label: "Long" - value: "long" - - label: "Short" - value: "short" + - long + - short turn_off: target: diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index c0f658c3a44..d4b709f65aa 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -93,7 +93,10 @@ "name": "Color temperature (Kelvin)" }, "effect": { - "name": "Effect" + "name": "Effect", + "state": { + "off": "[%key:common::state::off%]" + } }, "effect_list": { "name": "Available effects" @@ -280,6 +283,12 @@ "yellow": "Yellow", "yellowgreen": "Yellow green" } + }, + "flash": { + "options": { + "short": "Short", + "long": "Long" + } } }, "services": { diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py index 74e067f5eb3..0bfb34af42c 100644 --- a/homeassistant/components/linkplay/entity.py +++ b/homeassistant/components/linkplay/entity.py @@ -4,13 +4,13 @@ from collections.abc import Callable, Coroutine from typing import Any, Concatenate from linkplay.bridge import LinkPlayBridge +from linkplay.manufacturers import MANUFACTURER_GENERIC, get_info_from_project from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity from . import DOMAIN, LinkPlayRequestException -from .utils import MANUFACTURER_GENERIC, get_info_from_project def exception_wrap[_LinkPlayEntityT: LinkPlayBaseEntity, **_P, _R]( diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index ec9a8759a30..02acd0f04f4 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.1.3"], + "requirements": ["python-linkplay==0.2.2"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index b27616f1e09..16b0d5f75f1 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -86,16 +86,10 @@ REPEAT_MAP: dict[LoopMode, RepeatMode] = { REPEAT_MAP_INV: dict[RepeatMode, LoopMode] = {v: k for k, v in REPEAT_MAP.items()} -EQUALIZER_MAP: dict[EqualizerMode, str] = { - EqualizerMode.NONE: "None", - EqualizerMode.CLASSIC: "Classic", - EqualizerMode.POP: "Pop", - EqualizerMode.JAZZ: "Jazz", - EqualizerMode.VOCAL: "Vocal", +EQUALIZER_MAP_INV: dict[str, EqualizerMode] = { + mode.value: mode for mode in EqualizerMode } -EQUALIZER_MAP_INV: dict[str, EqualizerMode] = {v: k for k, v in EQUALIZER_MAP.items()} - DEFAULT_FEATURES: MediaPlayerEntityFeature = ( MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA @@ -148,7 +142,6 @@ async def async_setup_entry( class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): """Representation of a LinkPlay media player.""" - _attr_sound_mode_list = list(EQUALIZER_MAP.values()) _attr_device_class = MediaPlayerDeviceClass.RECEIVER _attr_media_content_type = MediaType.MUSIC _attr_name = None @@ -163,6 +156,9 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): self._attr_source_list = [ SOURCE_MAP[playing_mode] for playing_mode in bridge.device.playmode_support ] + self._attr_sound_mode_list = [ + mode.value for mode in bridge.player.available_equalizer_modes + ] @exception_wrap async def async_update(self) -> None: @@ -348,7 +344,7 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): self._attr_is_volume_muted = self._bridge.player.muted self._attr_repeat = REPEAT_MAP[self._bridge.player.loop_mode] self._attr_shuffle = self._bridge.player.loop_mode == LoopMode.RANDOM_PLAYBACK - self._attr_sound_mode = EQUALIZER_MAP[self._bridge.player.equalizer_mode] + self._attr_sound_mode = self._bridge.player.equalizer_mode.value self._attr_supported_features = DEFAULT_FEATURES if self._bridge.player.status == PlayingStatus.PLAYING: diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index 7151ed1537a..63d04a3afc4 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -1,7 +1,5 @@ """Utilities for the LinkPlay component.""" -from typing import Final - from aiohttp import ClientSession from linkplay.utils import async_create_unverified_client_session @@ -10,75 +8,6 @@ from homeassistant.core import Event, HomeAssistant, callback from .const import DATA_SESSION, DOMAIN -MANUFACTURER_ARTSOUND: Final[str] = "ArtSound" -MANUFACTURER_ARYLIC: Final[str] = "Arylic" -MANUFACTURER_IEAST: Final[str] = "iEAST" -MANUFACTURER_WIIM: Final[str] = "WiiM" -MANUFACTURER_GGMM: Final[str] = "GGMM" -MANUFACTURER_MEDION: Final[str] = "Medion" -MANUFACTURER_GENERIC: Final[str] = "Generic" -MODELS_ARTSOUND_SMART_ZONE4: Final[str] = "Smart Zone 4 AMP" -MODELS_ARTSOUND_SMART_HYDE: Final[str] = "Smart Hyde" -MODELS_ARYLIC_S50: Final[str] = "S50+" -MODELS_ARYLIC_S50_PRO: Final[str] = "S50 Pro" -MODELS_ARYLIC_A30: Final[str] = "A30" -MODELS_ARYLIC_A50: Final[str] = "A50" -MODELS_ARYLIC_A50S: Final[str] = "A50+" -MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0" -MODELS_ARYLIC_UP2STREAM_AMP_2P1: Final[str] = "Up2Stream Amp 2.1" -MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3" -MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4" -MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1" -MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3" -MODELS_ARYLIC_S10P: Final[str] = "Arylic S10+" -MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp" -MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5" -MODELS_WIIM_AMP: Final[str] = "WiiM Amp" -MODELS_WIIM_MINI: Final[str] = "WiiM Mini" -MODELS_GGMM_GGMM_E2: Final[str] = "GGMM E2" -MODELS_MEDION_MD_43970: Final[str] = "Life P66970 (MD 43970)" -MODELS_GENERIC: Final[str] = "Generic" - -PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = { - "SMART_ZONE4_AMP": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4), - "SMART_HYDE": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE), - "ARYLIC_S50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50), - "RP0016_S50PRO_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO), - "RP0011_WB60_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30), - "X-50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50), - "ARYLIC_A50S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S), - "RP0011_WB60": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP), - "UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3), - "UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4), - "UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3), - "S10P_WIFI": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S10P), - "ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP), - "UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_2P1), - "RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_S50A": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0010_D5_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0001": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0013_WA31S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0010_D5": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0013_WA31S_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0014_A50D_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_A50TE": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_A50N": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "iEAST-02": (MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5), - "WiiM_Amp_4layer": (MANUFACTURER_WIIM, MODELS_WIIM_AMP), - "Muzo_Mini": (MANUFACTURER_WIIM, MODELS_WIIM_MINI), - "GGMM_E2A": (MANUFACTURER_GGMM, MODELS_GGMM_GGMM_E2), - "A16": (MANUFACTURER_MEDION, MODELS_MEDION_MD_43970), -} - - -def get_info_from_project(project: str) -> tuple[str, str]: - """Get manufacturer and model info based on given project.""" - return PROJECTID_LOOKUP.get(project, (MANUFACTURER_GENERIC, MODELS_GENERIC)) - async def async_get_client_session(hass: HomeAssistant) -> ClientSession: """Get a ClientSession that can be used with LinkPlay devices.""" diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 052427f3032..55dbc0ea645 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -118,9 +118,9 @@ "brightness_level": { "name": "Panel brightness", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index fc6d0bc00c7..90cd5a6d2ac 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==9.0.1"] + "requirements": ["ical==9.1.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 27d3ccce4a7..a630c18c669 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==9.0.1"] + "requirements": ["ical==9.1.0"] } diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index fd8636acf97..fd2854b7932 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -28,7 +28,7 @@ "locked": "[%key:common::state::locked%]", "locking": "Locking", "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "unlocked": "[%key:common::state::unlocked%]", "unlocking": "Unlocking" }, diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 40b904c1279..f27a470a23d 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast +from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast, final from propcache.api import cached_property from sqlalchemy.engine.row import Row @@ -114,6 +114,7 @@ DATA_POS: Final = 11 CONTEXT_POS: Final = 12 +@final # Final to allow direct checking of the type instead of using isinstance class EventAsRow(NamedTuple): """Convert an event to a row. diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 4d8472da9a2..c0262f42f6c 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol -from homeassistant.components import frontend, websocket_api +from homeassistant.components import frontend, onboarding, websocket_api from homeassistant.config import ( async_hass_config_yaml, async_process_component_and_handle_errors, @@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.frame import report_usage from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.util import slugify @@ -282,6 +283,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: STORAGE_DASHBOARD_UPDATE_FIELDS, ).async_setup(hass) + def create_map_dashboard() -> None: + """Create a map dashboard.""" + hass.async_create_task(_create_map_dashboard(hass, dashboards_collection)) + + if not onboarding.async_is_onboarded(hass): + onboarding.async_add_listener(hass, create_map_dashboard) + return True @@ -323,3 +331,25 @@ def _register_panel( kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON) frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs) + + +async def _create_map_dashboard( + hass: HomeAssistant, dashboards_collection: dashboard.DashboardsCollection +) -> None: + """Create a map dashboard.""" + translations = await async_get_translations( + hass, hass.config.language, "dashboard", {onboarding.DOMAIN} + ) + title = translations["component.onboarding.dashboard.map.title"] + + await dashboards_collection.async_create_item( + { + CONF_ALLOW_SINGLE_WORD: True, + CONF_ICON: "mdi:map", + CONF_TITLE: title, + CONF_URL_PATH: "map", + } + ) + + map_store = hass.data[LOVELACE_DATA].dashboards["map"] + await map_store.async_save({"strategy": {"type": "map"}}) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index b5665e5d47a..a55df58cac7 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -265,4 +265,61 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterBinarySensor, required_attributes=(clusters.SmokeCoAlarm.Attributes.InterconnectCOAlarm,), ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvseChargingStatusSensor", + translation_key="evse_charging_status", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + measurement_to_ha={ + clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: False, + clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False, + clusters.EnergyEvse.Enums.StateEnum.kFault: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.State,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvsePlugStateSensor", + translation_key="evse_plug_state", + device_class=BinarySensorDeviceClass.PLUG, + measurement_to_ha={ + clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: True, + clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False, + clusters.EnergyEvse.Enums.StateEnum.kFault: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.State,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvseSupplyStateSensor", + translation_key="evse_supply_charging_state", + device_class=BinarySensorDeviceClass.RUNNING, + measurement_to_ha={ + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.SupplyState,), + allow_multi=True, # also used for sensor entity + ), ] diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 96696193466..fded57d34f5 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -61,6 +61,7 @@ class MatterEntityDescription(EntityDescription): # convert the value from the primary attribute to the value used by HA measurement_to_ha: Callable[[Any], Any] | None = None ha_to_native_value: Callable[[Any], Any] | None = None + command_timeout: int | None = None class MatterEntity(Entity): diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 6fa775fd1b9..fa7d96ed1ae 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -69,7 +69,7 @@ class MatterEventEntity(MatterEntity, EventEntity): max_presses_supported = self.get_matter_attribute_value( clusters.Switch.Attributes.MultiPressMax ) - max_presses_supported = min(max_presses_supported or 1, 8) + max_presses_supported = min(max_presses_supported or 2, 8) for i in range(max_presses_supported): event_types.append(f"multi_press_{i + 1}") # noqa: PERF401 elif feature_map & SwitchFeature.kMomentarySwitch: diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index f9217cabcc4..fed51708870 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -71,6 +71,15 @@ }, "battery_replacement_description": { "default": "mdi:battery-sync-outline" + }, + "evse_state": { + "default": "mdi:ev-station" + }, + "evse_supply_state": { + "default": "mdi:ev-station" + }, + "evse_fault_state": { + "default": "mdi:ev-station" } }, "switch": { @@ -80,6 +89,9 @@ "on": "mdi:lock", "off": "mdi:lock-off" } + }, + "evse_charging_switch": { + "default": "mdi:ev-station" } } } diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 44538f46856..2c7a9651c60 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -169,8 +169,8 @@ DISCOVERY_SCHEMAS = [ device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, translation_key="temperature_offset", - native_max_value=25, - native_min_value=-25, + native_max_value=50, + native_min_value=-50, native_step=0.5, native_unit_of_measurement=UnitOfTemperature.CELSIUS, measurement_to_ha=lambda x: None if x is None else x / 10, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 10f8db275f5..82d8ec1727c 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -77,6 +77,25 @@ OPERATIONAL_STATE_MAP = { clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", } +EVSE_FAULT_STATE_MAP = { + clusters.EnergyEvse.Enums.FaultStateEnum.kNoError: "no_error", + clusters.EnergyEvse.Enums.FaultStateEnum.kMeterFailure: "meter_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverVoltage: "over_voltage", + clusters.EnergyEvse.Enums.FaultStateEnum.kUnderVoltage: "under_voltage", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverCurrent: "over_current", + clusters.EnergyEvse.Enums.FaultStateEnum.kContactWetFailure: "contact_wet_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kContactDryFailure: "contact_dry_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kPowerLoss: "power_loss", + clusters.EnergyEvse.Enums.FaultStateEnum.kPowerQuality: "power_quality", + clusters.EnergyEvse.Enums.FaultStateEnum.kPilotShortCircuit: "pilot_short_circuit", + clusters.EnergyEvse.Enums.FaultStateEnum.kEmergencyStop: "emergency_stop", + clusters.EnergyEvse.Enums.FaultStateEnum.kEVDisconnected: "ev_disconnected", + clusters.EnergyEvse.Enums.FaultStateEnum.kWrongPowerSupply: "wrong_power_supply", + clusters.EnergyEvse.Enums.FaultStateEnum.kLiveNeutralSwap: "live_neutral_swap", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverTemperature: "over_temperature", + clusters.EnergyEvse.Enums.FaultStateEnum.kOther: "other", +} + async def async_setup_entry( hass: HomeAssistant, @@ -904,4 +923,77 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported state list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseFaultState", + translation_key="evse_fault_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(EVSE_FAULT_STATE_MAP.values()), + measurement_to_ha=EVSE_FAULT_STATE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.FaultState,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseCircuitCapacity", + translation_key="evse_circuit_capacity", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.CircuitCapacity,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseMinimumChargeCurrent", + translation_key="evse_min_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.MinimumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseMaximumChargeCurrent", + translation_key="evse_max_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.MaximumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseUserMaximumChargeCurrent", + translation_key="evse_user_max_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.UserMaximumChargeCurrent,), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 1404d0a9076..54db8c695e6 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -76,6 +76,15 @@ }, "muted": { "name": "Muted" + }, + "evse_charging_status": { + "name": "Charging status" + }, + "evse_plug": { + "name": "Plug state" + }, + "evse_supply_charging_state": { + "name": "Supply charging state" } }, "button": { @@ -135,9 +144,9 @@ "state_attributes": { "preset_mode": { "state": { - "low": "Low", - "medium": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "auto": "Auto", "natural_wind": "Natural wind", "sleep_wind": "Sleep wind" @@ -160,7 +169,7 @@ "name": "On/Off transition time" }, "altitude": { - "name": "Altitude above Sea Level" + "name": "Altitude above sea level" }, "temperature_offset": { "name": "Temperature offset" @@ -189,9 +198,9 @@ "sensitivity_level": { "name": "Sensitivity", "state": { - "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "low": "[%key:common::state::low%]", "standard": "Standard", - "high": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::high%]" + "high": "[%key:common::state::high%]" } }, "startup_on_off": { @@ -213,7 +222,7 @@ "name": "Number of rinses", "state": { "off": "[%key:common::state::off%]", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "extra": "Extra", "max": "Max" } @@ -229,8 +238,8 @@ "contamination_state": { "name": "Contamination state", "state": { - "normal": "Normal", - "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "normal": "[%key:common::state::normal%]", + "low": "[%key:common::state::low%]", "warning": "Warning", "critical": "Critical" } @@ -258,7 +267,7 @@ "operational_state": { "name": "Operational state", "state": { - "stopped": "Stopped", + "stopped": "[%key:common::state::stopped%]", "running": "Running", "paused": "[%key:common::state::paused%]", "error": "Error", @@ -278,6 +287,42 @@ }, "current_phase": { "name": "Current phase" + }, + "evse_fault_state": { + "name": "Fault state", + "state": { + "no_error": "OK", + "meter_failure": "Meter failure", + "over_voltage": "Overvoltage", + "under_voltage": "Undervoltage", + "over_current": "Overcurrent", + "contact_wet_failure": "Contact wet failure", + "contact_dry_failure": "Contact dry failure", + "power_loss": "Power loss", + "power_quality": "Power quality", + "pilot_short_circuit": "Pilot short circuit", + "emergency_stop": "Emergency stop", + "ev_disconnected": "EV disconnected", + "wrong_power_supply": "Wrong power supply", + "live_neutral_swap": "Live/neutral swap", + "over_temperature": "Overtemperature", + "other": "Other fault" + } + }, + "evse_circuit_capacity": { + "name": "Circuit capacity" + }, + "evse_charge_current": { + "name": "Charge current" + }, + "evse_min_charge_current": { + "name": "Min charge current" + }, + "evse_max_charge_current": { + "name": "Max charge current" + }, + "evse_user_max_charge_current": { + "name": "User max charge current" } }, "switch": { @@ -289,6 +334,9 @@ }, "child_lock": { "name": "Child lock" + }, + "evse_charging_switch": { + "name": "Enable charging" } }, "vacuum": { diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index af4803af9a1..870a9098492 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any from chip.clusters import Objects as clusters +from chip.clusters.Objects import ClusterCommand, NullValue from matter_server.client.models import device_types from homeassistant.components.switch import ( @@ -22,6 +24,13 @@ from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema +EVSE_SUPPLY_STATE_MAP = { + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False, +} + async def async_setup_entry( hass: HomeAssistant, @@ -58,6 +67,66 @@ class MatterSwitch(MatterEntity, SwitchEntity): ) +class MatterGenericCommandSwitch(MatterSwitch): + """Representation of a Matter switch.""" + + entity_description: MatterGenericCommandSwitchEntityDescription + + _platform_translation_key = "switch" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + if self.entity_description.on_command: + # custom command defined to set the new value + await self.send_device_command( + self.entity_description.on_command(), + self.entity_description.command_timeout, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + if self.entity_description.off_command: + await self.send_device_command( + self.entity_description.off_command(), + self.entity_description.command_timeout, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.measurement_to_ha: + value = value_convert(value) + self._attr_is_on = value + + async def send_device_command( + self, + command: ClusterCommand, + command_timeout: int | None = None, + **kwargs: Any, + ) -> None: + """Send device command with timeout.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + timed_request_timeout_ms=command_timeout, + **kwargs, + ) + + +@dataclass(frozen=True) +class MatterGenericCommandSwitchEntityDescription( + SwitchEntityDescription, MatterEntityDescription +): + """Describe Matter Generic command Switch entities.""" + + # command: a custom callback to create the command to send to the device + on_command: Callable[[], Any] | None = None + off_command: Callable[[], Any] | None = None + command_timeout: int | None = None + + @dataclass(frozen=True) class MatterNumericSwitchEntityDescription( SwitchEntityDescription, MatterEntityDescription @@ -194,4 +263,26 @@ DISCOVERY_SCHEMAS = [ ), vendor_id=(4874,), ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=MatterGenericCommandSwitchEntityDescription( + key="EnergyEvseChargingSwitch", + translation_key="evse_charging_switch", + on_command=lambda: clusters.EnergyEvse.Commands.EnableCharging( + chargingEnabledUntil=NullValue, + minimumChargeCurrent=0, + maximumChargeCurrent=0, + ), + off_command=clusters.EnergyEvse.Commands.Disable, + command_timeout=3000, + measurement_to_ha=EVSE_SUPPLY_STATE_MAP.get, + ), + entity_class=MatterGenericCommandSwitch, + required_attributes=( + clusters.EnergyEvse.Attributes.SupplyState, + clusters.EnergyEvse.Attributes.AcceptedCommandList, + ), + value_contains=clusters.EnergyEvse.Commands.EnableCharging.command_id, + allow_multi=True, + ), ] diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py index 4a2b4da990d..a2a148dffd5 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -3,12 +3,15 @@ from __future__ import annotations from dataclasses import dataclass +from typing import cast +from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from homeassistant.helpers import llm +from homeassistant.helpers import config_entry_oauth2_flow, llm -from .const import DOMAIN -from .coordinator import ModelContextProtocolCoordinator +from .application_credentials import authorization_server_context +from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .coordinator import ModelContextProtocolCoordinator, TokenManager from .types import ModelContextProtocolConfigEntry __all__ = [ @@ -20,11 +23,45 @@ __all__ = [ API_PROMPT = "The following tools are available from a remote server named {name}." +async def async_get_config_entry_implementation( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> config_entry_oauth2_flow.AbstractOAuth2Implementation | None: + """OAuth implementation for the config entry.""" + if "auth_implementation" not in entry.data: + return None + with authorization_server_context( + AuthorizationServer( + authorize_url=entry.data[CONF_AUTHORIZATION_URL], + token_url=entry.data[CONF_TOKEN_URL], + ) + ): + return await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + + +async def _create_token_manager( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> TokenManager | None: + """Create a OAuth token manager for the config entry if the server requires authentication.""" + if not (implementation := await async_get_config_entry_implementation(hass, entry)): + return None + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + async def token_manager() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) + + return token_manager + + async def async_setup_entry( hass: HomeAssistant, entry: ModelContextProtocolConfigEntry ) -> bool: """Set up Model Context Protocol from a config entry.""" - coordinator = ModelContextProtocolCoordinator(hass, entry) + token_manager = await _create_token_manager(hass, entry) + coordinator = ModelContextProtocolCoordinator(hass, entry, token_manager) await coordinator.async_config_entry_first_refresh() unsub = llm.async_register_api( @@ -39,7 +76,6 @@ async def async_setup_entry( entry.async_on_unload(unsub) entry.runtime_data = coordinator - entry.async_on_unload(coordinator.close) return True diff --git a/homeassistant/components/mcp/application_credentials.py b/homeassistant/components/mcp/application_credentials.py new file mode 100644 index 00000000000..9b8bed894e4 --- /dev/null +++ b/homeassistant/components/mcp/application_credentials.py @@ -0,0 +1,35 @@ +"""Application credentials platform for Model Context Protocol.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +import contextvars + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +CONF_ACTIVE_AUTHORIZATION_SERVER = "active_authorization_server" + +_mcp_context: contextvars.ContextVar[AuthorizationServer] = contextvars.ContextVar( + "mcp_authorization_server_context" +) + + +@contextmanager +def authorization_server_context( + authorization_server: AuthorizationServer, +) -> Generator[None]: + """Context manager for setting the active authorization server.""" + token = _mcp_context.set(authorization_server) + try: + yield + finally: + _mcp_context.reset(token) + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server, for the default auth implementation.""" + if _mcp_context.get() is None: + raise RuntimeError("No MCP authorization server set in context") + return _mcp_context.get() diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 92e0052c665..0f34962f7ee 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -2,20 +2,29 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from typing import Any +from typing import Any, cast import httpx import voluptuous as vol +from yarl import URL -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_URL +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_entry_oauth2_flow import ( + AbstractOAuth2FlowHandler, + async_get_implementations, +) -from .const import DOMAIN -from .coordinator import mcp_client +from . import async_get_config_entry_implementation +from .application_credentials import authorization_server_context +from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .coordinator import TokenManager, mcp_client _LOGGER = logging.getLogger(__name__) @@ -25,8 +34,62 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +# OAuth server discovery endpoint for rfc8414 +OAUTH_DISCOVERY_ENDPOINT = ".well-known/oauth-authorization-server" +MCP_DISCOVERY_HEADERS = { + "MCP-Protocol-Version": "2025-03-26", +} -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + +async def async_discover_oauth_config( + hass: HomeAssistant, mcp_server_url: str +) -> AuthorizationServer: + """Discover the OAuth configuration for the MCP server. + + This implements the functionality in the MCP spec for discovery. If the MCP server URL + is https://api.example.com/v1/mcp, then: + - The authorization base URL is https://api.example.com + - The metadata endpoint MUST be at https://api.example.com/.well-known/oauth-authorization-server + - For servers that do not implement OAuth 2.0 Authorization Server Metadata, the client uses + default paths relative to the authorization base URL. + """ + parsed_url = URL(mcp_server_url) + discovery_endpoint = str(parsed_url.with_path(OAUTH_DISCOVERY_ENDPOINT)) + try: + async with httpx.AsyncClient(headers=MCP_DISCOVERY_HEADERS) as client: + response = await client.get(discovery_endpoint) + response.raise_for_status() + except httpx.TimeoutException as error: + _LOGGER.info("Timeout connecting to MCP server: %s", error) + raise TimeoutConnectError from error + except httpx.HTTPStatusError as error: + if error.response.status_code == 404: + _LOGGER.info("Authorization Server Metadata not found, using default paths") + return AuthorizationServer( + authorize_url=str(parsed_url.with_path("/authorize")), + token_url=str(parsed_url.with_path("/token")), + ) + raise CannotConnect from error + except httpx.HTTPError as error: + _LOGGER.info("Cannot discover OAuth configuration: %s", error) + raise CannotConnect from error + + data = response.json() + authorize_url = data["authorization_endpoint"] + token_url = data["token_endpoint"] + if authorize_url.startswith("/"): + authorize_url = str(parsed_url.with_path(authorize_url)) + if token_url.startswith("/"): + token_url = str(parsed_url.with_path(token_url)) + return AuthorizationServer( + authorize_url=authorize_url, + token_url=token_url, + ) + + +async def validate_input( + hass: HomeAssistant, data: dict[str, Any], token_manager: TokenManager | None = None +) -> dict[str, Any]: """Validate the user input and connect to the MCP server.""" url = data[CONF_URL] try: @@ -34,7 +97,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except vol.Invalid as error: raise InvalidUrl from error try: - async with mcp_client(url) as session: + async with mcp_client(url, token_manager=token_manager) as session: response = await session.initialize() except httpx.TimeoutException as error: _LOGGER.info("Timeout connecting to MCP server: %s", error) @@ -56,10 +119,17 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return {"title": response.serverInfo.name} -class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): +class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle a config flow for Model Context Protocol.""" VERSION = 1 + DOMAIN = DOMAIN + logger = _LOGGER + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.data: dict[str, Any] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -76,7 +146,8 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: - return self.async_abort(reason="invalid_auth") + self.data[CONF_URL] = user_input[CONF_URL] + return await self.async_step_auth_discovery() except MissingCapabilities: return self.async_abort(reason="missing_capabilities") except Exception: @@ -90,6 +161,130 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_auth_discovery( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the OAuth server discovery step. + + Since this OAuth server requires authentication, this step will attempt + to find the OAuth medata then run the OAuth authentication flow. + """ + try: + authorization_server = await async_discover_oauth_config( + self.hass, self.data[CONF_URL] + ) + except TimeoutConnectError: + return self.async_abort(reason="timeout_connect") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + else: + _LOGGER.info("OAuth configuration: %s", authorization_server) + self.data.update( + { + CONF_AUTHORIZATION_URL: authorization_server.authorize_url, + CONF_TOKEN_URL: authorization_server.token_url, + } + ) + return await self.async_step_credentials_choice() + + def authorization_server(self) -> AuthorizationServer: + """Return the authorization server provided by the MCP server.""" + return AuthorizationServer( + self.data[CONF_AUTHORIZATION_URL], + self.data[CONF_TOKEN_URL], + ) + + async def async_step_credentials_choice( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to ask they user if they would like to add credentials. + + This is needed since we can't automatically assume existing credentials + should be used given they may be for another existing server. + """ + with authorization_server_context(self.authorization_server()): + if not await async_get_implementations(self.hass, self.DOMAIN): + return await self.async_step_new_credentials() + return self.async_show_menu( + step_id="credentials_choice", + menu_options=["pick_implementation", "new_credentials"], + ) + + async def async_step_new_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to take the frontend flow to enter new credentials.""" + return self.async_abort(reason="missing_credentials") + + async def async_step_pick_implementation( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the pick implementation step. + + This exists to dynamically set application credentials Authorization Server + based on the values form the OAuth discovery step. + """ + with authorization_server_context(self.authorization_server()): + return await super().async_step_pick_implementation(user_input) + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an entry for the flow. + + Ok to override if you want to fetch extra info or even add another step. + """ + config_entry_data = { + **self.data, + **data, + } + + async def token_manager() -> str: + return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + + try: + info = await validate_input(self.hass, config_entry_data, token_manager) + except TimeoutConnectError: + return self.async_abort(reason="timeout_connect") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except MissingCapabilities: + return self.async_abort(reason="missing_capabilities") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + # Unique id based on the application credentials OAuth Client ID + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=config_entry_data + ) + await self.async_set_unique_id(config_entry_data["auth_implementation"]) + return self.async_create_entry( + title=info["title"], + data=config_entry_data, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + config_entry = self._get_reauth_entry() + self.data = {**config_entry.data} + self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment] + self.hass, config_entry + ) + return await self.async_step_auth() + class InvalidUrl(HomeAssistantError): """Error to indicate the URL format is invalid.""" diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py index 675b2d7031c..13f63b02c73 100644 --- a/homeassistant/components/mcp/const.py +++ b/homeassistant/components/mcp/const.py @@ -1,3 +1,7 @@ """Constants for the Model Context Protocol integration.""" DOMAIN = "mcp" + +CONF_ACCESS_TOKEN = "access_token" +CONF_AUTHORIZATION_URL = "authorization_url" +CONF_TOKEN_URL = "token_url" diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index a5c5ee55dbf..f560875292f 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -1,7 +1,7 @@ """Types for the Model Context Protocol integration.""" import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager import datetime import logging @@ -15,7 +15,7 @@ from voluptuous_openapi import convert_to_voluptuous from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import llm from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.json import JsonObjectType @@ -27,19 +27,32 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = datetime.timedelta(minutes=30) TIMEOUT = 10 +TokenManager = Callable[[], Awaitable[str]] + @asynccontextmanager -async def mcp_client(url: str) -> AsyncGenerator[ClientSession]: +async def mcp_client( + url: str, + token_manager: TokenManager | None = None, +) -> AsyncGenerator[ClientSession]: """Create a server-sent event MCP client. This is an asynccontext manager that exists to wrap other async context managers so that the coordinator has a single object to manage. """ + headers: dict[str, str] = {} + if token_manager is not None: + token = await token_manager() + headers["Authorization"] = f"Bearer {token}" try: - async with sse_client(url=url) as streams, ClientSession(*streams) as session: + async with ( + sse_client(url=url, headers=headers) as streams, + ClientSession(*streams) as session, + ): await session.initialize() yield session except ExceptionGroup as err: + _LOGGER.debug("Error creating MCP client: %s", err) raise err.exceptions[0] from err @@ -51,13 +64,15 @@ class ModelContextProtocolTool(llm.Tool): name: str, description: str | None, parameters: vol.Schema, - session: ClientSession, + server_url: str, + token_manager: TokenManager | None = None, ) -> None: """Initialize the tool.""" self.name = name self.description = description self.parameters = parameters - self.session = session + self.server_url = server_url + self.token_manager = token_manager async def async_call( self, @@ -67,10 +82,16 @@ class ModelContextProtocolTool(llm.Tool): ) -> JsonObjectType: """Call the tool.""" try: - result = await self.session.call_tool( - tool_input.tool_name, tool_input.tool_args - ) + async with asyncio.timeout(TIMEOUT): + async with mcp_client(self.server_url, self.token_manager) as session: + result = await session.call_tool( + tool_input.tool_name, tool_input.tool_args + ) + except TimeoutError as error: + _LOGGER.debug("Timeout when calling tool: %s", error) + raise HomeAssistantError(f"Timeout when calling tool: {error}") from error except httpx.HTTPStatusError as error: + _LOGGER.debug("Error when calling tool: %s", error) raise HomeAssistantError(f"Error when calling tool: {error}") from error return result.model_dump(exclude_unset=True, exclude_none=True) @@ -79,10 +100,13 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): """Define an object to hold MCP data.""" config_entry: ConfigEntry - _session: ClientSession | None = None - _setup_error: Exception | None = None - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + token_manager: TokenManager | None = None, + ) -> None: """Initialize ModelContextProtocolCoordinator.""" super().__init__( hass, @@ -91,52 +115,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): config_entry=config_entry, update_interval=UPDATE_INTERVAL, ) - self._stop = asyncio.Event() - - async def _async_setup(self) -> None: - """Set up the client connection.""" - connected = asyncio.Event() - stop = asyncio.Event() - self.config_entry.async_create_background_task( - self.hass, self._connect(connected, stop), "mcp-client" - ) - try: - async with asyncio.timeout(TIMEOUT): - await connected.wait() - self._stop = stop - finally: - if self._setup_error is not None: - raise self._setup_error - - async def _connect(self, connected: asyncio.Event, stop: asyncio.Event) -> None: - """Create a server-sent event MCP client.""" - url = self.config_entry.data[CONF_URL] - try: - async with ( - sse_client(url=url) as streams, - ClientSession(*streams) as session, - ): - await session.initialize() - self._session = session - connected.set() - await stop.wait() - except httpx.HTTPStatusError as err: - self._setup_error = err - _LOGGER.debug("Error connecting to MCP server: %s", err) - raise UpdateFailed(f"Error connecting to MCP server: {err}") from err - except ExceptionGroup as err: - self._setup_error = err.exceptions[0] - _LOGGER.debug("Error connecting to MCP server: %s", err) - raise UpdateFailed( - "Error connecting to MCP server: {err.exceptions[0]}" - ) from err.exceptions[0] - finally: - self._session = None - - async def close(self) -> None: - """Close the client connection.""" - if self._stop is not None: - self._stop.set() + self.token_manager = token_manager async def _async_update_data(self) -> list[llm.Tool]: """Fetch data from API endpoint. @@ -144,11 +123,24 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): This is the place to pre-process the data to lookup tables so entities can quickly look up their data. """ - if self._session is None: - raise UpdateFailed("No session available") try: - result = await self._session.list_tools() + async with asyncio.timeout(TIMEOUT): + async with mcp_client( + self.config_entry.data[CONF_URL], self.token_manager + ) as session: + result = await session.list_tools() + except TimeoutError as error: + _LOGGER.debug("Timeout when listing tools: %s", error) + raise UpdateFailed(f"Timeout when listing tools: {error}") from error + except httpx.HTTPStatusError as error: + _LOGGER.debug("Error communicating with API: %s", error) + if error.response.status_code == 401 and self.token_manager is not None: + raise ConfigEntryAuthFailed( + "The MCP server requires authentication" + ) from error + raise UpdateFailed(f"Error communicating with API: {error}") from error except httpx.HTTPError as err: + _LOGGER.debug("Error communicating with API: %s", err) raise UpdateFailed(f"Error communicating with API: {err}") from err _LOGGER.debug("Received tools: %s", result.tools) @@ -165,7 +157,8 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): tool.name, tool.description, parameters, - self._session, + self.config_entry.data[CONF_URL], + self.token_manager, ) ) return tools diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json index ee4baf04802..7ff64d29aa4 100644 --- a/homeassistant/components/mcp/manifest.json +++ b/homeassistant/components/mcp/manifest.json @@ -3,8 +3,9 @@ "name": "Model Context Protocol", "codeowners": ["@allenporter"], "config_flow": true, + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/mcp", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["mcp==1.1.2"] + "requirements": ["mcp==1.5.0"] } diff --git a/homeassistant/components/mcp/quality_scale.yaml b/homeassistant/components/mcp/quality_scale.yaml index 76afdf5860d..f22343c8d0e 100644 --- a/homeassistant/components/mcp/quality_scale.yaml +++ b/homeassistant/components/mcp/quality_scale.yaml @@ -44,9 +44,7 @@ rules: parallel-updates: status: exempt comment: Integration does not have platforms. - reauthentication-flow: - status: exempt - comment: Integration does not support authentication. + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index 97a75fc6f85..2b59d4ffa51 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -8,6 +8,15 @@ "data_description": { "url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse" } + }, + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "Credentials" + }, + "data_description": { + "implementation": "The credentials to use for the OAuth2 flow" + } } }, "error": { @@ -17,9 +26,15 @@ "invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse" }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_capabilities": "The MCP server does not support a required capability (Tools)", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py deleted file mode 100644 index f7dd4421480..00000000000 --- a/homeassistant/components/mcp_server/llm_api.py +++ /dev/null @@ -1,41 +0,0 @@ -"""LLM API for MCP Server. - -This is a modified version of the AssistAPI that does not include the home state -in the prompt. This API is not registered with the LLM API registry since it is -only used by the MCP Server. The MCP server will substitute this API when the -user selects the Assist API. -""" - -from homeassistant.core import callback -from homeassistant.helpers import llm -from homeassistant.util import yaml as yaml_util - -EXPOSED_ENTITY_FIELDS = {"name", "domain", "description", "areas", "names"} - - -class StatelessAssistAPI(llm.AssistAPI): - """LLM API for MCP Server that provides the Assist API without state information in the prompt. - - Syncing the state information is possible, but may put unnecessary load on - the system so we are instead providing the prompt without entity state. Since - actions don't care about the current state, there is little quality loss. - """ - - @callback - def _async_get_exposed_entities_prompt( - self, llm_context: llm.LLMContext, exposed_entities: dict | None - ) -> list[str]: - """Return the prompt for the exposed entities.""" - prompt = [] - - if exposed_entities and exposed_entities["entities"]: - prompt.append( - "An overview of the areas and the devices in this smart home:" - ) - entities = [ - {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} - for entity_info in exposed_entities["entities"].values() - ] - prompt.append(yaml_util.dump(list(entities))) - - return prompt diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index 18b2e5bc417..b5fb1bdcd87 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.8.0"], + "requirements": ["mcp==1.5.0", "aiohttp_sse==2.2.0", "anyio==4.9.0"], "single_config_entry": true } diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 307fcdda8f3..affa4faecd6 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -22,7 +22,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import llm from .const import STATELESS_LLM_API -from .llm_api import StatelessAssistAPI _LOGGER = logging.getLogger(__name__) @@ -50,15 +49,14 @@ async def create_server( A Model Context Protocol Server object is associated with a single session. The MCP SDK handles the details of the protocol. """ + if llm_api_id == STATELESS_LLM_API: + llm_api_id = llm.LLM_API_ASSIST - server = Server("home-assistant") + server = Server[Any]("home-assistant") async def get_api_instance() -> llm.APIInstance: - """Substitute the StatelessAssistAPI for the Assist API if selected.""" - if llm_api_id in (STATELESS_LLM_API, llm.LLM_API_ASSIST): - api = StatelessAssistAPI(hass) - return await api.async_get_api_instance(llm_context) - + """Get the LLM API selected.""" + # Backwards compatibility with old MCP Server config return await llm.async_get_api(hass, llm_api_id, llm_context) @server.list_prompts() # type: ignore[no-untyped-call, misc] diff --git a/homeassistant/components/meater/config_flow.py b/homeassistant/components/meater/config_flow.py index a7ba3ba1498..5c11b10755c 100644 --- a/homeassistant/components/meater/config_flow.py +++ b/homeassistant/components/meater/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from meater import AuthenticationError, MeaterApi, ServiceUnavailableError @@ -14,6 +15,8 @@ from homeassistant.helpers import aiohttp_client from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) USER_SCHEMA = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} @@ -84,7 +87,8 @@ class MeaterConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except ServiceUnavailableError: errors["base"] = "service_unavailable_error" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown_auth_error" else: data = {"username": username, "password": password} diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 575c0fa878d..e049a827c75 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.02.19"], + "requirements": ["yt-dlp[default]==2025.03.26"], "single_config_entry": true } diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index c917164a2ee..d234050c1b2 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -23,7 +23,11 @@ from homeassistant.helpers.network import ( from .const import CONTENT_AUTH_EXPIRY_TIME, MediaClass, MediaType # Paths that we don't need to sign -PATHS_WITHOUT_AUTH = ("/api/tts_proxy/", "/api/esphome/ffmpeg_proxy/") +PATHS_WITHOUT_AUTH = ( + "/api/tts_proxy/", + "/api/esphome/ffmpeg_proxy/", + "/api/assist_satellite/static/", +) @callback diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 87b5ec692af..03106b431d7 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -344,7 +344,7 @@ }, "repeat": { "options": { - "off": "Off", + "off": "[%key:common::state::off%]", "all": "Repeat all", "one": "Repeat one" } diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 9c2ee60b12c..682a28ea080 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from typing import Any +from typing import Any, cast from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice import pymelcloud.ata_device as ata @@ -236,7 +236,7 @@ class AtaDeviceClimate(MelCloudClimate): set_dict: dict[str, Any] = {} if ATTR_HVAC_MODE in kwargs: self._apply_set_hvac_mode( - kwargs.get(ATTR_HVAC_MODE, self.hvac_mode), set_dict + cast(HVACMode, kwargs.get(ATTR_HVAC_MODE, self.hvac_mode)), set_dict ) if ATTR_TEMPERATURE in kwargs: diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 5c4ada6b5f1..5f1d5269538 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert isinstance(department, str) return await hass.async_add_executor_job( - client.get_warning_current_phenomenoms, department, 0, True + client.get_warning_current_phenomenons, department, 0, True ) coordinator_forecast = DataUpdateCoordinator( diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 567788ec479..d82d0c3f91b 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteo_france", "iot_class": "cloud_polling", "loggers": ["meteofrance_api"], - "requirements": ["meteofrance-api==1.3.0"] + "requirements": ["meteofrance-api==1.4.0"] } diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index c29cc1ceda9..7333f7b0c19 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -7,7 +7,7 @@ from typing import Any from meteofrance_api.helpers import ( get_warning_text_status_from_indice_color, - readeable_phenomenoms_dict, + readable_phenomenons_dict, ) from meteofrance_api.model.forecast import Forecast from meteofrance_api.model.rain import Rain @@ -336,7 +336,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor[CurrentPhenomenons]): def extra_state_attributes(self): """Return the state attributes.""" return { - **readeable_phenomenoms_dict(self.coordinator.data.phenomenons_max_colors), + **readable_phenomenons_dict(self.coordinator.data.phenomenons_max_colors), } diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index 169da7a0a18..6e508bd63d8 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -102,6 +102,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, icon="mdi:weather-windy", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key="rain", diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 347549dc837..7d1578558b0 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -88,11 +88,11 @@ }, "duplicate_entity_entry": { "title": "Modbus {sub_1} address {sub_2} is duplicate, second entry not loaded.", - "description": "An address can only be associated with one entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An address can only be associated with one entity. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "duplicate_entity_name": { "title": "Modbus {sub_1} is duplicate, second entry not loaded.", - "description": "A entity name must be unique, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An entity name must be unique. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "no_entities": { "title": "Modbus {sub_1} contain no entities, entry not loaded.", diff --git a/homeassistant/components/mopeka/strings.json b/homeassistant/components/mopeka/strings.json index 2455eea2f76..23feb554772 100644 --- a/homeassistant/components/mopeka/strings.json +++ b/homeassistant/components/mopeka/strings.json @@ -6,7 +6,7 @@ "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { "address": "[%key:common::config_flow::data::device%]", - "medium_type": "Medium Type" + "medium_type": "Medium type" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index a7bb34af1e6..954f9e25c21 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from motionblinds import MotionDiscovery, MotionGateway @@ -28,6 +29,8 @@ from .const import ( ) from .gateway import ConnectMotionGateway +_LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema( { vol.Optional(CONF_HOST): str, @@ -93,7 +96,8 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): try: # key not needed for GetDeviceList request await self.hass.async_add_executor_job(gateway.GetDeviceList) - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Failed to connect to Motion Gateway") return self.async_abort(reason="not_motionblinds") if not gateway.available: diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index ddbf928462a..12060cd69f0 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -3,20 +3,20 @@ "flow_title": "{short_mac} ({ip_address})", "step": { "user": { - "description": "Connect to your Motion Gateway, if the IP address is not set, auto-discovery is used", + "description": "Connect to your Motionblinds gateway. If the IP address is not set, auto-discovery is used", "data": { "host": "[%key:common::config_flow::data::ip%]" } }, "connect": { - "description": "You will need the 16 character API Key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key for instructions", + "description": "You will need the 16 character API key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-api-key for instructions", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } }, "select": { - "title": "Select the Motion Gateway that you wish to connect", - "description": "Run the setup again if you want to connect additional Motion Gateways", + "title": "Select the Motionblinds gateway that you wish to connect", + "description": "Run the setup again if you want to connect additional Motionblinds gateways", "data": { "select_ip": "[%key:common::config_flow::data::ip%]" } @@ -29,7 +29,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "connection_error": "[%key:common::config_flow::error::cannot_connect%]", - "not_motionblinds": "Discovered device is not a Motion gateway" + "not_motionblinds": "Discovered device is not a Motionblinds gateway" } }, "options": { diff --git a/homeassistant/components/motionblinds_ble/strings.json b/homeassistant/components/motionblinds_ble/strings.json index d6532f12386..4589c2d873b 100644 --- a/homeassistant/components/motionblinds_ble/strings.json +++ b/homeassistant/components/motionblinds_ble/strings.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_bluetooth_adapter": "No bluetooth adapter found", - "no_devices_found": "Could not find any bluetooth devices" + "no_bluetooth_adapter": "No Bluetooth adapter found", + "no_devices_found": "Could not find any Bluetooth devices" }, "error": { "could_not_find_motor": "Could not find a motor with that MAC code", @@ -62,9 +62,9 @@ "speed": { "name": "Speed", "state": { - "1": "Low", - "2": "Medium", - "3": "High" + "1": "[%key:common::state::low%]", + "2": "[%key:common::state::medium%]", + "3": "[%key:common::state::high%]" } } }, @@ -72,8 +72,8 @@ "connection": { "name": "Connection status", "state": { - "connected": "Connected", - "disconnected": "Disconnected", + "connected": "[%key:common::state::connected%]", + "disconnected": "[%key:common::state::disconnected%]", "connecting": "Connecting", "disconnecting": "Disconnecting" } diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index a8fcc84f2ec..861faa319cd 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -46,6 +46,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-preset" self._presets: list[motionmount.Preset] = [] + self._attr_current_option = None def _update_options(self, presets: list[motionmount.Preset]) -> None: """Convert presets to select options.""" diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 8dfccbb6b2a..83592c4c23d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -27,6 +27,13 @@ import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigEntry, @@ -45,18 +52,22 @@ from homeassistant.const import ( ATTR_SW_VERSION, CONF_CLIENT_ID, CONF_DEVICE, + CONF_DEVICE_CLASS, CONF_DISCOVERY, CONF_HOST, CONF_NAME, + CONF_OPTIMISTIC, CONF_PASSWORD, CONF_PAYLOAD, CONF_PLATFORM, CONF_PORT, CONF_PROTOCOL, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.json import json_dumps @@ -99,11 +110,16 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_DISCOVERY_PREFIX, CONF_ENTITY_PICTURE, + CONF_EXPIRE_AFTER, CONF_KEEPALIVE, + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_OPTIONS, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + CONF_STATE_TOPIC, + CONF_SUGGESTED_DISPLAY_PRECISION, CONF_TLS_INSECURE, CONF_TRANSPORT, CONF_WILL_MESSAGE, @@ -120,6 +136,7 @@ from .const import ( DEFAULT_PORT, DEFAULT_PREFIX, DEFAULT_PROTOCOL, + DEFAULT_QOS, DEFAULT_TRANSPORT, DEFAULT_WILL, DEFAULT_WS_PATH, @@ -133,9 +150,9 @@ from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData from .util import ( async_create_certificate_temp_files, get_file_path, + learn_more_url, valid_birth_will, valid_publish_topic, - valid_qos_schema, valid_subscribe_topic, valid_subscribe_topic_template, ) @@ -164,7 +181,6 @@ PASSWORD_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWO QOS_SELECTOR = NumberSelector( NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2) ) -QOS_DATA_SCHEMA = vol.All(QOS_SELECTOR, valid_qos_schema) KEEPALIVE_SELECTOR = vol.All( NumberSelector( NumberSelectorConfig( @@ -217,7 +233,7 @@ KEY_UPLOAD_SELECTOR = FileSelector( ) # Subentry selectors -SUBENTRY_PLATFORMS = [Platform.NOTIFY] +SUBENTRY_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH] SUBENTRY_PLATFORM_SELECTOR = SelectSelector( SelectSelectorConfig( options=[platform.value for platform in SUBENTRY_PLATFORMS], @@ -225,7 +241,6 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector( translation_key=CONF_PLATFORM, ) ) - TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( @@ -241,52 +256,280 @@ SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( } ) +# Sensor specific selectors +SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in SensorDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_sensor", + sort=True, + ) +) +SENSOR_STATE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in SensorStateClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_STATE_CLASS, + ) +) +OPTIONS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[], + custom_value=True, + multiple=True, + ) +) +SUGGESTED_DISPLAY_PRECISION_SELECTOR = NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=9) +) +EXPIRE_AFTER_SELECTOR = NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0) +) -@dataclass(frozen=True) +# Switch specific selectors +SWITCH_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in SwitchDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_switch", + ) +) + + +@callback +def validate_sensor_platform_config( + config: dict[str, Any], +) -> dict[str, str]: + """Validate the sensor options, state and device class config.""" + errors: dict[str, str] = {} + # Only allow `options` to be set for `enum` sensors + # to limit the possible sensor values + if config.get(CONF_OPTIONS) is not None: + if config.get(CONF_STATE_CLASS) or config.get(CONF_UNIT_OF_MEASUREMENT): + errors[CONF_OPTIONS] = "options_not_allowed_with_state_class_or_uom" + + if (device_class := config.get(CONF_DEVICE_CLASS)) != SensorDeviceClass.ENUM: + errors[CONF_DEVICE_CLASS] = "options_device_class_enum" + + if ( + (device_class := config.get(CONF_DEVICE_CLASS)) == SensorDeviceClass.ENUM + and errors is not None + and CONF_OPTIONS not in config + ): + errors[CONF_OPTIONS] = "options_with_enum_device_class" + + if ( + device_class in DEVICE_CLASS_UNITS + and (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is None + and errors is not None + ): + # Do not allow an empty unit of measurement in a subentry data flow + errors[CONF_UNIT_OF_MEASUREMENT] = "uom_required_for_device_class" + return errors + + if ( + device_class is not None + and device_class in DEVICE_CLASS_UNITS + and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] + ): + errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom" + + return errors + + +@dataclass(frozen=True, kw_only=True) class PlatformField: """Stores a platform config field schema, required flag and validator.""" - selector: Selector + selector: Selector[Any] | Callable[..., Selector[Any]] required: bool validator: Callable[..., Any] error: str | None = None default: str | int | vol.Undefined = vol.UNDEFINED exclude_from_reconfig: bool = False + conditions: tuple[dict[str, Any], ...] | None = None + custom_filtering: bool = False + section: str | None = None + + +@callback +def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: + """Return a context based unit of measurement selector.""" + if ( + user_data is None + or (device_class := user_data.get(CONF_DEVICE_CLASS)) is None + or device_class not in DEVICE_CLASS_UNITS + ): + return TEXT_SELECTOR + return SelectSelector( + SelectSelectorConfig( + options=[str(uom) for uom in DEVICE_CLASS_UNITS[device_class]], + sort=True, + custom_value=True, + ) + ) COMMON_ENTITY_FIELDS = { CONF_PLATFORM: PlatformField( - SUBENTRY_PLATFORM_SELECTOR, True, str, exclude_from_reconfig=True + selector=SUBENTRY_PLATFORM_SELECTOR, + required=True, + validator=str, + exclude_from_reconfig=True, + ), + CONF_NAME: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + exclude_from_reconfig=True, + ), + CONF_ENTITY_PICTURE: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url" ), - CONF_NAME: PlatformField(TEXT_SELECTOR, False, str, exclude_from_reconfig=True), - CONF_ENTITY_PICTURE: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"), } -COMMON_MQTT_FIELDS = { - CONF_QOS: PlatformField(QOS_SELECTOR, False, valid_qos_schema, default=0), - CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), +PLATFORM_ENTITY_FIELDS = { + Platform.NOTIFY.value: {}, + Platform.SENSOR.value: { + CONF_DEVICE_CLASS: PlatformField( + selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False, validator=str + ), + CONF_STATE_CLASS: PlatformField( + selector=SENSOR_STATE_CLASS_SELECTOR, required=False, validator=str + ), + CONF_UNIT_OF_MEASUREMENT: PlatformField( + selector=unit_of_measurement_selector, + required=False, + validator=str, + custom_filtering=True, + ), + CONF_SUGGESTED_DISPLAY_PRECISION: PlatformField( + selector=SUGGESTED_DISPLAY_PRECISION_SELECTOR, + required=False, + validator=cv.positive_int, + section="advanced_settings", + ), + CONF_OPTIONS: PlatformField( + selector=OPTIONS_SELECTOR, + required=False, + validator=cv.ensure_list, + conditions=({"device_class": "enum"},), + ), + }, + Platform.SWITCH.value: { + CONF_DEVICE_CLASS: PlatformField( + selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False, validator=str + ), + }, } PLATFORM_MQTT_FIELDS = { Platform.NOTIFY.value: { CONF_COMMAND_TOPIC: PlatformField( - TEXT_SELECTOR, True, valid_publish_topic, "invalid_publish_topic" + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", ), CONF_COMMAND_TEMPLATE: PlatformField( - TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + }, + Platform.SENSOR.value: { + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_STATE_CLASS: "total"},), + ), + CONF_EXPIRE_AFTER: PlatformField( + selector=EXPIRE_AFTER_SELECTOR, + required=False, + validator=cv.positive_int, + section="advanced_settings", + ), + }, + Platform.SWITCH.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + CONF_OPTIMISTIC: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool ), }, } +ENTITY_CONFIG_VALIDATOR: dict[ + str, + Callable[[dict[str, Any]], dict[str, str]] | None, +] = { + Platform.NOTIFY.value: None, + Platform.SENSOR.value: validate_sensor_platform_config, + Platform.SWITCH.value: None, +} -MQTT_DEVICE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_NAME): TEXT_SELECTOR, - vol.Optional(ATTR_SW_VERSION): TEXT_SELECTOR, - vol.Optional(ATTR_HW_VERSION): TEXT_SELECTOR, - vol.Optional(ATTR_MODEL): TEXT_SELECTOR, - vol.Optional(ATTR_MODEL_ID): TEXT_SELECTOR, - vol.Optional(ATTR_CONFIGURATION_URL): TEXT_SELECTOR, - } -) +MQTT_DEVICE_PLATFORM_FIELDS = { + ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str), + ATTR_SW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=str + ), + ATTR_HW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=str + ), + ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str), + ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str), + ATTR_CONFIGURATION_URL: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url" + ), + CONF_QOS: PlatformField( + selector=QOS_SELECTOR, + required=False, + validator=int, + default=DEFAULT_QOS, + section="mqtt_settings", + ), +} REAUTH_SCHEMA = vol.Schema( { @@ -337,38 +580,151 @@ def validate_field( errors[field] = error +@callback +def _check_conditions( + platform_field: PlatformField, component_data: dict[str, Any] | None = None +) -> bool: + """Only include field if one of conditions match, or no conditions are set.""" + if platform_field.conditions is None or component_data is None: + return True + return any( + all(component_data.get(key) == value for key, value in condition.items()) + for condition in platform_field.conditions + ) + + +@callback +def calculate_merged_config( + merged_user_input: dict[str, Any], + data_schema_fields: dict[str, PlatformField], + component_data: dict[str, Any], +) -> dict[str, Any]: + """Calculate merged config.""" + base_schema_fields = { + key + for key, platform_field in data_schema_fields.items() + if _check_conditions(platform_field, component_data) + } - set(merged_user_input) + return { + key: value + for key, value in component_data.items() + if key not in base_schema_fields + } | merged_user_input + + @callback def validate_user_input( user_input: dict[str, Any], data_schema_fields: dict[str, PlatformField], - errors: dict[str, str], -) -> None: + *, + component_data: dict[str, Any] | None = None, + config_validator: Callable[[dict[str, Any]], dict[str, str]] | None = None, +) -> tuple[dict[str, Any], dict[str, str]]: """Validate user input.""" - for field, value in user_input.items(): + errors: dict[str, str] = {} + # Merge sections + merged_user_input: dict[str, Any] = {} + for key, value in user_input.items(): + if isinstance(value, dict): + merged_user_input.update(value) + else: + merged_user_input[key] = value + + for field, value in merged_user_input.items(): validator = data_schema_fields[field].validator try: validator(value) except (ValueError, vol.Invalid): errors[field] = data_schema_fields[field].error or "invalid_input" + if config_validator is not None: + if TYPE_CHECKING: + assert component_data is not None + + errors |= config_validator( + calculate_merged_config( + merged_user_input, data_schema_fields, component_data + ), + ) + + return merged_user_input, errors + @callback def data_schema_from_fields( data_schema_fields: dict[str, PlatformField], reconfig: bool, + component_data: dict[str, Any] | None = None, + user_input: dict[str, Any] | None = None, + device_data: MqttDeviceData | None = None, ) -> vol.Schema: - """Generate data schema from platform fields.""" - return vol.Schema( - { + """Generate custom data schema from platform fields or device data.""" + if device_data is not None: + component_data_with_user_input: dict[str, Any] | None = dict(device_data) + if TYPE_CHECKING: + assert component_data_with_user_input is not None + component_data_with_user_input.update( + component_data_with_user_input.pop("mqtt_settings", {}) + ) + else: + component_data_with_user_input = deepcopy(component_data) + if component_data_with_user_input is not None and user_input is not None: + component_data_with_user_input |= user_input + + sections: dict[str | None, None] = { + field_details.section: None for field_details in data_schema_fields.values() + } + data_schema: dict[Any, Any] = {} + all_data_element_options: set[Any] = set() + no_reconfig_options: set[Any] = set() + for schema_section in sections: + data_schema_element = { vol.Required(field_name, default=field_details.default) if field_details.required else vol.Optional( field_name, default=field_details.default - ): field_details.selector + ): field_details.selector(component_data_with_user_input) # type: ignore[operator] + if field_details.custom_filtering + else field_details.selector for field_name, field_details in data_schema_fields.items() - if not field_details.exclude_from_reconfig or not reconfig + if field_details.section == schema_section + and (not field_details.exclude_from_reconfig or not reconfig) + and _check_conditions(field_details, component_data_with_user_input) } - ) + data_element_options = set(data_schema_element) + all_data_element_options |= data_element_options + no_reconfig_options |= { + field_name + for field_name, field_details in data_schema_fields.items() + if field_details.section == schema_section + and field_details.exclude_from_reconfig + } + if schema_section is None: + data_schema.update(data_schema_element) + continue + collapsed = ( + not any( + (default := data_schema_fields[str(option)].default) is vol.UNDEFINED + or component_data_with_user_input[str(option)] != default + for option in data_element_options + if option in component_data_with_user_input + ) + if component_data_with_user_input is not None + else True + ) + data_schema[vol.Optional(schema_section)] = section( + vol.Schema(data_schema_element), SectionConfig({"collapsed": collapsed}) + ) + + # Reset all fields from the component_data not in the schema + if component_data: + filtered_fields = ( + set(data_schema_fields) - all_data_element_options - no_reconfig_options + ) + for field in filtered_fields: + if field in component_data: + del component_data[field] + return vol.Schema(data_schema) class FlowHandler(ConfigFlow, domain=DOMAIN): @@ -849,7 +1205,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): "birth_payload", description={"suggested_value": birth[CONF_PAYLOAD]} ) ] = TEXT_SELECTOR - fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = QOS_DATA_SCHEMA + fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = QOS_SELECTOR fields[vol.Optional("birth_retain", default=birth[ATTR_RETAIN])] = ( BOOLEAN_SELECTOR ) @@ -872,7 +1228,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): "will_payload", description={"suggested_value": will[CONF_PAYLOAD]} ) ] = TEXT_SELECTOR - fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = QOS_DATA_SCHEMA + fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = QOS_SELECTOR fields[vol.Optional("will_retain", default=will[ATTR_RETAIN])] = ( BOOLEAN_SELECTOR ) @@ -893,20 +1249,56 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): @callback def update_component_fields( - self, data_schema: vol.Schema, user_input: dict[str, Any] + self, + data_schema_fields: dict[str, PlatformField], + merged_user_input: dict[str, Any], ) -> None: """Update the componment fields.""" if TYPE_CHECKING: assert self._component_id is not None component_data = self._subentry_data["components"][self._component_id] - # Remove the fields from the component data if they are not in the user input - for field in [ - form_field - for form_field in data_schema.schema - if form_field in component_data and form_field not in user_input - ]: + # Remove the fields from the component data + # if they are not in the schema and not in the user input + config = calculate_merged_config( + merged_user_input, data_schema_fields, component_data + ) + for field in ( + field + for field, platform_field in data_schema_fields.items() + if field in (set(component_data) - set(config)) + and not platform_field.exclude_from_reconfig + ): component_data.pop(field) - component_data.update(user_input) + component_data.update(merged_user_input) + + @callback + def generate_names(self) -> tuple[str, str]: + """Generate the device and full entity name.""" + if TYPE_CHECKING: + assert self._component_id is not None + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + if entity_name := self._subentry_data["components"][self._component_id].get( + CONF_NAME + ): + full_entity_name: str = f"{device_name} {entity_name}" + else: + full_entity_name = device_name + return device_name, full_entity_name + + @callback + def get_suggested_values_from_component( + self, data_schema: vol.Schema + ) -> dict[str, Any]: + """Get suggestions from component data based on the data schema.""" + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + return { + field_key: self.get_suggested_values_from_component(value.schema) + if isinstance(value, section) + else component_data.get(field_key) + for field_key, value in data_schema.schema.items() + } async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -929,17 +1321,22 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Add a new MQTT device.""" - errors: dict[str, str] = {} - validate_field("configuration_url", cv.url, user_input, errors, "invalid_url") - if not errors and user_input is not None: - self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input) - if self.source == SOURCE_RECONFIGURE: - return await self.async_step_summary_menu() - return await self.async_step_entity() - + errors: dict[str, Any] = {} + device_data = self._subentry_data[CONF_DEVICE] + data_schema = data_schema_from_fields( + MQTT_DEVICE_PLATFORM_FIELDS, + device_data=device_data, + reconfig=True, + ) + if user_input is not None: + _, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS) + if not errors: + self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input) + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_summary_menu() + return await self.async_step_entity() data_schema = self.add_suggested_values_to_schema( - MQTT_DEVICE_SCHEMA, - self._subentry_data[CONF_DEVICE] if user_input is None else user_input, + data_schema, device_data if user_input is None else user_input ) return self.async_show_form( step_id=CONF_DEVICE, @@ -956,25 +1353,28 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): data_schema_fields = COMMON_ENTITY_FIELDS entity_name_label: str = "" platform_label: str = "" + component_data: dict[str, Any] | None = None if reconfig := (self._component_id is not None): - name: str | None = self._subentry_data["components"][ - self._component_id - ].get(CONF_NAME) + component_data = self._subentry_data["components"][self._component_id] + name: str | None = component_data.get(CONF_NAME) platform_label = f"{self._subentry_data['components'][self._component_id][CONF_PLATFORM]} " entity_name_label = f" ({name})" if name is not None else "" data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig) if user_input is not None: - validate_user_input(user_input, data_schema_fields, errors) + merged_user_input, errors = validate_user_input( + user_input, data_schema_fields, component_data=component_data + ) if not errors: if self._component_id is None: self._component_id = uuid4().hex self._subentry_data["components"].setdefault(self._component_id, {}) - self.update_component_fields(data_schema, user_input) - return await self.async_step_mqtt_platform_config() + self.update_component_fields(data_schema_fields, merged_user_input) + return await self.async_step_entity_platform_config() data_schema = self.add_suggested_values_to_schema(data_schema, user_input) elif self.source == SOURCE_RECONFIGURE and self._component_id is not None: data_schema = self.add_suggested_values_to_schema( - data_schema, self._subentry_data["components"][self._component_id] + data_schema, + self.get_suggested_values_from_component(data_schema), ) device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] return self.async_show_form( @@ -994,9 +1394,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] entities = [ SelectOptionDict( - value=key, label=f"{device_name} {component.get(CONF_NAME, '-')}" + value=key, + label=f"{device_name} {component_data.get(CONF_NAME, '-')}" + f" ({component_data[CONF_PLATFORM]})", ) - for key, component in self._subentry_data["components"].items() + for key, component_data in self._subentry_data["components"].items() ] data_schema = vol.Schema( { @@ -1034,6 +1436,61 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): return await self.async_step_summary_menu() return self._show_update_or_delete_form("delete_entity") + async def async_step_entity_platform_config( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Configure platform entity details.""" + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + platform = component_data[CONF_PLATFORM] + data_schema_fields = PLATFORM_ENTITY_FIELDS[platform] + errors: dict[str, str] = {} + + data_schema = data_schema_from_fields( + data_schema_fields, + reconfig=bool( + {field for field in data_schema_fields if field in component_data} + ), + component_data=component_data, + user_input=user_input, + ) + if not data_schema.schema: + return await self.async_step_mqtt_platform_config() + if user_input is not None: + # Test entity fields against the validator + merged_user_input, errors = validate_user_input( + user_input, + data_schema_fields, + component_data=component_data, + config_validator=ENTITY_CONFIG_VALIDATOR[platform], + ) + if not errors: + self.update_component_fields(data_schema_fields, merged_user_input) + return await self.async_step_mqtt_platform_config() + + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + else: + data_schema = self.add_suggested_values_to_schema( + data_schema, + self.get_suggested_values_from_component(data_schema), + ) + + device_name, full_entity_name = self.generate_names() + return self.async_show_form( + step_id="entity_platform_config", + data_schema=data_schema, + description_placeholders={ + "mqtt_device": device_name, + CONF_PLATFORM: platform, + "entity": full_entity_name, + "url": learn_more_url(platform), + } + | (user_input or {}), + errors=errors, + last_step=False, + ) + async def async_step_mqtt_platform_config( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -1041,16 +1498,26 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): errors: dict[str, str] = {} if TYPE_CHECKING: assert self._component_id is not None - platform = self._subentry_data["components"][self._component_id][CONF_PLATFORM] - data_schema_fields = PLATFORM_MQTT_FIELDS[platform] | COMMON_MQTT_FIELDS + component_data = self._subentry_data["components"][self._component_id] + platform = component_data[CONF_PLATFORM] + data_schema_fields = PLATFORM_MQTT_FIELDS[platform] data_schema = data_schema_from_fields( - data_schema_fields, reconfig=self._component_id is not None + data_schema_fields, + reconfig=bool( + {field for field in data_schema_fields if field in component_data} + ), + component_data=component_data, ) if user_input is not None: # Test entity fields against the validator - validate_user_input(user_input, data_schema_fields, errors) + merged_user_input, errors = validate_user_input( + user_input, + data_schema_fields, + component_data=component_data, + config_validator=ENTITY_CONFIG_VALIDATOR[platform], + ) if not errors: - self.update_component_fields(data_schema, user_input) + self.update_component_fields(data_schema_fields, merged_user_input) self._component_id = None if self.source == SOURCE_RECONFIGURE: return await self.async_step_summary_menu() @@ -1059,16 +1526,10 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): data_schema = self.add_suggested_values_to_schema(data_schema, user_input) else: data_schema = self.add_suggested_values_to_schema( - data_schema, self._subentry_data["components"][self._component_id] + data_schema, + self.get_suggested_values_from_component(data_schema), ) - device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] - entity_name: str | None - if entity_name := self._subentry_data["components"][self._component_id].get( - CONF_NAME - ): - full_entity_name: str = f"{device_name} {entity_name}" - else: - full_entity_name = device_name + device_name, full_entity_name = self.generate_names() return self.async_show_form( step_id="mqtt_platform_config", data_schema=data_schema, @@ -1076,6 +1537,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): "mqtt_device": device_name, CONF_PLATFORM: platform, "entity": full_entity_name, + "url": learn_more_url(platform), }, errors=errors, last_step=False, @@ -1087,12 +1549,12 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """Create a subentry for a new MQTT device.""" device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] - component: dict[str, Any] = next( + component_data: dict[str, Any] = next( iter(self._subentry_data["components"].values()) ) - platform = component[CONF_PLATFORM] + platform = component_data[CONF_PLATFORM] entity_name: str | None - if entity_name := component.get(CONF_NAME): + if entity_name := component_data.get(CONF_NAME): full_entity_name: str = f"{device_name} {entity_name}" else: full_entity_name = device_name @@ -1151,8 +1613,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): self._component_id = None mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME] mqtt_items = ", ".join( - f"{mqtt_device} {component.get(CONF_NAME, '-')}" - for component in self._subentry_data["components"].values() + f"{mqtt_device} {component_data.get(CONF_NAME, '-')} ({component_data[CONF_PLATFORM]})" + for component_data in self._subentry_data["components"].values() ) menu_options = [ "entity", @@ -1176,7 +1638,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Save the changes made to the subentry.""" - entry = self._get_reconfigure_entry() + entry = self._get_entry() subentry = self._get_reconfigure_subentry() entity_registry = er.async_get(self.hass) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 007b3b7e576..b2fcd492435 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -56,20 +56,55 @@ CONF_SUPPORTED_FEATURES = "supported_features" CONF_ACTION_TEMPLATE = "action_template" CONF_ACTION_TOPIC = "action_topic" +CONF_BLUE_TEMPLATE = "blue_template" +CONF_BRIGHTNESS_COMMAND_TEMPLATE = "brightness_command_template" +CONF_BRIGHTNESS_COMMAND_TOPIC = "brightness_command_topic" +CONF_BRIGHTNESS_SCALE = "brightness_scale" +CONF_BRIGHTNESS_STATE_TOPIC = "brightness_state_topic" +CONF_BRIGHTNESS_TEMPLATE = "brightness_template" +CONF_BRIGHTNESS_VALUE_TEMPLATE = "brightness_value_template" +CONF_COLOR_MODE = "color_mode" +CONF_COLOR_MODE_STATE_TOPIC = "color_mode_state_topic" +CONF_COLOR_MODE_VALUE_TEMPLATE = "color_mode_value_template" +CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template" +CONF_COLOR_TEMP_COMMAND_TOPIC = "color_temp_command_topic" CONF_COLOR_TEMP_KELVIN = "color_temp_kelvin" +CONF_COLOR_TEMP_TEMPLATE = "color_temp_template" +CONF_COLOR_TEMP_STATE_TOPIC = "color_temp_state_topic" +CONF_COLOR_TEMP_VALUE_TEMPLATE = "color_temp_value_template" +CONF_COMMAND_OFF_TEMPLATE = "command_off_template" +CONF_COMMAND_ON_TEMPLATE = "command_on_template" CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" CONF_ENABLED_BY_DEFAULT = "enabled_by_default" +CONF_EFFECT_COMMAND_TEMPLATE = "effect_command_template" +CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic" +CONF_EFFECT_LIST = "effect_list" +CONF_EFFECT_STATE_TOPIC = "effect_state_topic" +CONF_EFFECT_TEMPLATE = "effect_template" +CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" CONF_ENTITY_PICTURE = "entity_picture" +CONF_EXPIRE_AFTER = "expire_after" +CONF_FLASH_TIME_LONG = "flash_time_long" +CONF_FLASH_TIME_SHORT = "flash_time_short" +CONF_GREEN_TEMPLATE = "green_template" +CONF_HS_COMMAND_TEMPLATE = "hs_command_template" +CONF_HS_COMMAND_TOPIC = "hs_command_topic" +CONF_HS_STATE_TOPIC = "hs_state_topic" +CONF_HS_VALUE_TEMPLATE = "hs_value_template" +CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" CONF_MAX_KELVIN = "max_kelvin" +CONF_MAX_MIREDS = "max_mireds" CONF_MIN_KELVIN = "min_kelvin" +CONF_MIN_MIREDS = "min_mireds" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" CONF_MODE_STATE_TEMPLATE = "mode_state_template" CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_ON_COMMAND_TYPE = "on_command_type" CONF_PAYLOAD_CLOSE = "payload_close" CONF_PAYLOAD_OPEN = "payload_open" CONF_PAYLOAD_STOP = "payload_stop" @@ -78,10 +113,25 @@ CONF_POSITION_OPEN = "position_open" CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRECISION = "precision" +CONF_RED_TEMPLATE = "red_template" +CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template" +CONF_RGB_COMMAND_TOPIC = "rgb_command_topic" +CONF_RGB_STATE_TOPIC = "rgb_state_topic" +CONF_RGB_VALUE_TEMPLATE = "rgb_value_template" +CONF_RGBW_COMMAND_TEMPLATE = "rgbw_command_template" +CONF_RGBW_COMMAND_TOPIC = "rgbw_command_topic" +CONF_RGBW_STATE_TOPIC = "rgbw_state_topic" +CONF_RGBW_VALUE_TEMPLATE = "rgbw_value_template" +CONF_RGBWW_COMMAND_TEMPLATE = "rgbww_command_template" +CONF_RGBWW_COMMAND_TOPIC = "rgbww_command_topic" +CONF_RGBWW_STATE_TOPIC = "rgbww_state_topic" +CONF_RGBWW_VALUE_TEMPLATE = "rgbww_value_template" CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OPEN = "state_open" CONF_STATE_OPENING = "state_opening" +CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" +CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" @@ -89,7 +139,14 @@ CONF_TEMP_STATE_TOPIC = "temperature_state_topic" CONF_TEMP_INITIAL = "initial" CONF_TEMP_MAX = "max_temp" CONF_TEMP_MIN = "min_temp" +CONF_XY_COMMAND_TEMPLATE = "xy_command_template" +CONF_XY_COMMAND_TOPIC = "xy_command_topic" +CONF_XY_STATE_TOPIC = "xy_state_topic" +CONF_XY_VALUE_TEMPLATE = "xy_value_template" +CONF_WHITE_COMMAND_TOPIC = "white_command_topic" +CONF_WHITE_SCALE = "white_scale" +# Config flow constants CONF_CERTIFICATE = "certificate" CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" @@ -110,15 +167,23 @@ CONF_CONFIGURATION_URL = "configuration_url" CONF_OBJECT_ID = "object_id" CONF_SUPPORT_URL = "support_url" +DEFAULT_BRIGHTNESS = False +DEFAULT_BRIGHTNESS_SCALE = 255 DEFAULT_PREFIX = "homeassistant" DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status" DEFAULT_DISCOVERY = True +DEFAULT_EFFECT = False DEFAULT_ENCODING = "utf-8" +DEFAULT_FLASH_TIME_LONG = 10 +DEFAULT_FLASH_TIME_SHORT = 2 DEFAULT_OPTIMISTIC = False +DEFAULT_ON_COMMAND_TYPE = "last" DEFAULT_QOS = 0 DEFAULT_PAYLOAD_AVAILABLE = "online" DEFAULT_PAYLOAD_CLOSE = "CLOSE" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" +DEFAULT_PAYLOAD_OFF = "OFF" +DEFAULT_PAYLOAD_ON = "ON" DEFAULT_PAYLOAD_OPEN = "OPEN" DEFAULT_PORT = 1883 DEFAULT_RETAIN = False @@ -127,6 +192,7 @@ DEFAULT_WS_PATH = "/" DEFAULT_POSITION_CLOSED = 0 DEFAULT_POSITION_OPEN = 100 DEFAULT_RETAIN = False +DEFAULT_WHITE_SCALE = 255 PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a14240ce008..a527e712615 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -154,18 +154,14 @@ def get_origin_support_url(discovery_payload: MQTTDiscoveryPayload) -> str | Non @callback def async_log_discovery_origin_info( - message: str, discovery_payload: MQTTDiscoveryPayload, level: int = logging.INFO + message: str, discovery_payload: MQTTDiscoveryPayload ) -> None: """Log information about the discovery and origin.""" - # We only log origin info once per device discovery - if not _LOGGER.isEnabledFor(level): - # bail out early if logging is disabled + if not _LOGGER.isEnabledFor(logging.DEBUG): + # bail out early if debug logging is disabled return - _LOGGER.log( - level, - "%s%s", - message, - get_origin_log_string(discovery_payload, include_url=True), + _LOGGER.debug( + "%s%s", message, get_origin_log_string(discovery_payload, include_url=True) ) @@ -562,7 +558,7 @@ async def async_start( # noqa: C901 elif already_discovered: # Dispatch update message = f"Component has already been discovered: {component} {discovery_id}, sending update" - async_log_discovery_origin_info(message, payload, logging.DEBUG) + async_log_discovery_origin_info(message, payload) async_dispatcher_send( hass, MQTT_DISCOVERY_UPDATED.format(*discovery_hash), payload ) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 0b4f65fab47..2fe801b6a01 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -123,7 +123,7 @@ from .subscription import ( async_subscribe_topics_internal, async_unsubscribe_topics, ) -from .util import mqtt_config_entry_enabled +from .util import learn_more_url, mqtt_config_entry_enabled _LOGGER = logging.getLogger(__name__) @@ -300,6 +300,7 @@ def async_setup_entity_entry_helper( availability_config = subentry_data.get("availability", {}) subentry_entities: list[Entity] = [] device_config = subentry_data["device"].copy() + device_mqtt_options = device_config.pop("mqtt_settings", {}) device_config["identifiers"] = config_subentry_id for component_id, component_data in subentry_data["components"].items(): if component_data["platform"] != domain: @@ -311,6 +312,7 @@ def async_setup_entity_entry_helper( component_config[CONF_DEVICE] = device_config component_config.pop("platform") component_config.update(availability_config) + component_config.update(device_mqtt_options) try: config = platform_schema_modern(component_config) @@ -346,9 +348,6 @@ def async_setup_entity_entry_helper( line = getattr(yaml_config, "__line__", "?") issue_id = hex(hash(frozenset(yaml_config))) yaml_config_str = yaml_dump(yaml_config) - learn_more_url = ( - f"https://www.home-assistant.io/integrations/{domain}.mqtt/" - ) async_create_issue( hass, DOMAIN, @@ -356,7 +355,7 @@ def async_setup_entity_entry_helper( issue_domain=domain, is_fixable=False, severity=IssueSeverity.ERROR, - learn_more_url=learn_more_url, + learn_more_url=learn_more_url(domain), translation_placeholders={ "domain": domain, "config_file": config_file, @@ -400,6 +399,9 @@ class MqttAttributesMixin(Entity): _attributes_extra_blocked: frozenset[str] = frozenset() _attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None + _message_callback: Callable[ + [MessageCallbackType, set[str] | None, ReceiveMessage], None + ] def __init__(self, config: ConfigType) -> None: """Initialize the JSON attributes mixin.""" @@ -434,7 +436,7 @@ class MqttAttributesMixin(Entity): CONF_JSON_ATTRS_TOPIC: { "topic": self._attributes_config.get(CONF_JSON_ATTRS_TOPIC), "msg_callback": partial( - self._message_callback, # type: ignore[attr-defined] + self._message_callback, self._attributes_message_received, {"_attr_extra_state_attributes"}, ), @@ -483,6 +485,10 @@ class MqttAttributesMixin(Entity): class MqttAvailabilityMixin(Entity): """Mixin used for platforms that report availability.""" + _message_callback: Callable[ + [MessageCallbackType, set[str] | None, ReceiveMessage], None + ] + def __init__(self, config: ConfigType) -> None: """Initialize the availability mixin.""" self._availability_sub_state: dict[str, EntitySubscription] = {} @@ -548,7 +554,7 @@ class MqttAvailabilityMixin(Entity): f"availability_{topic}": { "topic": topic, "msg_callback": partial( - self._message_callback, # type: ignore[attr-defined] + self._message_callback, self._availability_message_received, {"available"}, ), diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index a2f424b247d..a950aced665 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -51,12 +51,58 @@ from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( + CONF_BRIGHTNESS_COMMAND_TEMPLATE, + CONF_BRIGHTNESS_COMMAND_TOPIC, + CONF_BRIGHTNESS_SCALE, + CONF_BRIGHTNESS_STATE_TOPIC, + CONF_BRIGHTNESS_VALUE_TEMPLATE, + CONF_COLOR_MODE_STATE_TOPIC, + CONF_COLOR_MODE_VALUE_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TOPIC, CONF_COLOR_TEMP_KELVIN, + CONF_COLOR_TEMP_STATE_TOPIC, + CONF_COLOR_TEMP_VALUE_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_EFFECT_COMMAND_TEMPLATE, + CONF_EFFECT_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_EFFECT_STATE_TOPIC, + CONF_EFFECT_VALUE_TEMPLATE, + CONF_HS_COMMAND_TEMPLATE, + CONF_HS_COMMAND_TOPIC, + CONF_HS_STATE_TOPIC, + CONF_HS_VALUE_TEMPLATE, CONF_MAX_KELVIN, + CONF_MAX_MIREDS, CONF_MIN_KELVIN, + CONF_MIN_MIREDS, + CONF_ON_COMMAND_TYPE, + CONF_RGB_COMMAND_TEMPLATE, + CONF_RGB_COMMAND_TOPIC, + CONF_RGB_STATE_TOPIC, + CONF_RGB_VALUE_TEMPLATE, + CONF_RGBW_COMMAND_TEMPLATE, + CONF_RGBW_COMMAND_TOPIC, + CONF_RGBW_STATE_TOPIC, + CONF_RGBW_VALUE_TEMPLATE, + CONF_RGBWW_COMMAND_TEMPLATE, + CONF_RGBWW_COMMAND_TOPIC, + CONF_RGBWW_STATE_TOPIC, + CONF_RGBWW_VALUE_TEMPLATE, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, + CONF_WHITE_COMMAND_TOPIC, + CONF_WHITE_SCALE, + CONF_XY_COMMAND_TEMPLATE, + CONF_XY_COMMAND_TOPIC, + CONF_XY_STATE_TOPIC, + CONF_XY_VALUE_TEMPLATE, + DEFAULT_BRIGHTNESS_SCALE, + DEFAULT_ON_COMMAND_TYPE, + DEFAULT_PAYLOAD_OFF, + DEFAULT_PAYLOAD_ON, + DEFAULT_WHITE_SCALE, PAYLOAD_NONE, ) from ..entity import MqttEntity @@ -74,47 +120,7 @@ from .schema import MQTT_LIGHT_SCHEMA_SCHEMA _LOGGER = logging.getLogger(__name__) -CONF_BRIGHTNESS_COMMAND_TEMPLATE = "brightness_command_template" -CONF_BRIGHTNESS_COMMAND_TOPIC = "brightness_command_topic" -CONF_BRIGHTNESS_SCALE = "brightness_scale" -CONF_BRIGHTNESS_STATE_TOPIC = "brightness_state_topic" -CONF_BRIGHTNESS_VALUE_TEMPLATE = "brightness_value_template" -CONF_COLOR_MODE_STATE_TOPIC = "color_mode_state_topic" -CONF_COLOR_MODE_VALUE_TEMPLATE = "color_mode_value_template" -CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template" -CONF_COLOR_TEMP_COMMAND_TOPIC = "color_temp_command_topic" -CONF_COLOR_TEMP_STATE_TOPIC = "color_temp_state_topic" -CONF_COLOR_TEMP_VALUE_TEMPLATE = "color_temp_value_template" -CONF_EFFECT_COMMAND_TEMPLATE = "effect_command_template" -CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic" -CONF_EFFECT_LIST = "effect_list" -CONF_EFFECT_STATE_TOPIC = "effect_state_topic" -CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" -CONF_HS_COMMAND_TEMPLATE = "hs_command_template" -CONF_HS_COMMAND_TOPIC = "hs_command_topic" -CONF_HS_STATE_TOPIC = "hs_state_topic" -CONF_HS_VALUE_TEMPLATE = "hs_value_template" -CONF_MAX_MIREDS = "max_mireds" -CONF_MIN_MIREDS = "min_mireds" -CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template" -CONF_RGB_COMMAND_TOPIC = "rgb_command_topic" -CONF_RGB_STATE_TOPIC = "rgb_state_topic" -CONF_RGB_VALUE_TEMPLATE = "rgb_value_template" -CONF_RGBW_COMMAND_TEMPLATE = "rgbw_command_template" -CONF_RGBW_COMMAND_TOPIC = "rgbw_command_topic" -CONF_RGBW_STATE_TOPIC = "rgbw_state_topic" -CONF_RGBW_VALUE_TEMPLATE = "rgbw_value_template" -CONF_RGBWW_COMMAND_TEMPLATE = "rgbww_command_template" -CONF_RGBWW_COMMAND_TOPIC = "rgbww_command_topic" -CONF_RGBWW_STATE_TOPIC = "rgbww_state_topic" -CONF_RGBWW_VALUE_TEMPLATE = "rgbww_value_template" -CONF_XY_COMMAND_TEMPLATE = "xy_command_template" -CONF_XY_COMMAND_TOPIC = "xy_command_topic" -CONF_XY_STATE_TOPIC = "xy_state_topic" -CONF_XY_VALUE_TEMPLATE = "xy_value_template" -CONF_WHITE_COMMAND_TOPIC = "white_command_topic" -CONF_WHITE_SCALE = "white_scale" -CONF_ON_COMMAND_TYPE = "on_command_type" +DEFAULT_NAME = "MQTT LightEntity" MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset( { @@ -137,13 +143,6 @@ MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset( } ) -DEFAULT_BRIGHTNESS_SCALE = 255 -DEFAULT_NAME = "MQTT LightEntity" -DEFAULT_PAYLOAD_OFF = "OFF" -DEFAULT_PAYLOAD_ON = "ON" -DEFAULT_WHITE_SCALE = 255 -DEFAULT_ON_COMMAND_TYPE = "last" - VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"] COMMAND_TEMPLATE_KEYS = [ diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index d18da9e917a..a1f86278cf0 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -55,13 +55,26 @@ from homeassistant.util.json import json_loads_object from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( + CONF_COLOR_MODE, CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_FLASH_TIME_LONG, + CONF_FLASH_TIME_SHORT, CONF_MAX_KELVIN, + CONF_MAX_MIREDS, CONF_MIN_KELVIN, + CONF_MIN_MIREDS, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + CONF_SUPPORTED_COLOR_MODES, + DEFAULT_BRIGHTNESS, + DEFAULT_BRIGHTNESS_SCALE, + DEFAULT_EFFECT, + DEFAULT_FLASH_TIME_LONG, + DEFAULT_FLASH_TIME_SHORT, + DEFAULT_WHITE_SCALE, ) from ..entity import MqttEntity from ..models import ReceiveMessage @@ -78,25 +91,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "mqtt_json" -DEFAULT_BRIGHTNESS = False -DEFAULT_EFFECT = False -DEFAULT_FLASH_TIME_LONG = 10 -DEFAULT_FLASH_TIME_SHORT = 2 DEFAULT_NAME = "MQTT JSON Light" -DEFAULT_BRIGHTNESS_SCALE = 255 -DEFAULT_WHITE_SCALE = 255 - -CONF_COLOR_MODE = "color_mode" -CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" - -CONF_EFFECT_LIST = "effect_list" - -CONF_FLASH_TIME_LONG = "flash_time_long" -CONF_FLASH_TIME_SHORT = "flash_time_short" - -CONF_MAX_MIREDS = "max_mireds" -CONF_MIN_MIREDS = "min_mireds" - _PLATFORM_SCHEMA_BASE = ( MQTT_RW_SCHEMA.extend( diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 901cee6f14c..f561f15fb51 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -40,10 +40,21 @@ from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( + CONF_BLUE_TEMPLATE, + CONF_BRIGHTNESS_TEMPLATE, CONF_COLOR_TEMP_KELVIN, + CONF_COLOR_TEMP_TEMPLATE, + CONF_COMMAND_OFF_TEMPLATE, + CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_EFFECT_TEMPLATE, + CONF_GREEN_TEMPLATE, CONF_MAX_KELVIN, + CONF_MAX_MIREDS, CONF_MIN_KELVIN, + CONF_MIN_MIREDS, + CONF_RED_TEMPLATE, CONF_STATE_TOPIC, PAYLOAD_NONE, ) @@ -51,6 +62,7 @@ from ..entity import MqttEntity from ..models import ( MqttCommandTemplate, MqttValueTemplate, + PayloadSentinel, PublishPayloadType, ReceiveMessage, ) @@ -64,18 +76,6 @@ DOMAIN = "mqtt_template" DEFAULT_NAME = "MQTT Template Light" -CONF_BLUE_TEMPLATE = "blue_template" -CONF_BRIGHTNESS_TEMPLATE = "brightness_template" -CONF_COLOR_TEMP_TEMPLATE = "color_temp_template" -CONF_COMMAND_OFF_TEMPLATE = "command_off_template" -CONF_COMMAND_ON_TEMPLATE = "command_on_template" -CONF_EFFECT_LIST = "effect_list" -CONF_EFFECT_TEMPLATE = "effect_template" -CONF_GREEN_TEMPLATE = "green_template" -CONF_MAX_MIREDS = "max_mireds" -CONF_MIN_MIREDS = "min_mireds" -CONF_RED_TEMPLATE = "red_template" - COMMAND_TEMPLATES = (CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_OFF_TEMPLATE) VALUE_TEMPLATES = ( CONF_BLUE_TEMPLATE, @@ -127,7 +127,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): _command_templates: dict[ str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType] ] - _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] + _value_templates: dict[ + str, Callable[[ReceivePayloadType, ReceivePayloadType], ReceivePayloadType] + ] _fixed_color_mode: ColorMode | str | None _topics: dict[str, str | None] @@ -204,73 +206,133 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): @callback def _state_received(self, msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" - state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload) - if state == STATE_ON: + state_value = self._value_templates[CONF_STATE_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not state_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty state value", msg.topic + ) + elif state_value == STATE_ON: self._attr_is_on = True - elif state == STATE_OFF: + elif state_value == STATE_OFF: self._attr_is_on = False - elif state == PAYLOAD_NONE: + elif state_value == PAYLOAD_NONE: self._attr_is_on = None else: - _LOGGER.warning("Invalid state value received") + _LOGGER.warning( + "Invalid state value '%s' received from %s", + state_value, + msg.topic, + ) if CONF_BRIGHTNESS_TEMPLATE in self._config: - try: - if brightness := int( - self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload) - ): - self._attr_brightness = brightness - else: - _LOGGER.debug( - "Ignoring zero brightness value for entity %s", - self.entity_id, + brightness_value = self._value_templates[CONF_BRIGHTNESS_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not brightness_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty brightness value", + msg.topic, + ) + else: + try: + if brightness := int(brightness_value): + self._attr_brightness = brightness + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + except ValueError: + _LOGGER.warning( + "Invalid brightness value '%s' received from %s", + brightness_value, + msg.topic, ) - except ValueError: - _LOGGER.warning("Invalid brightness value received from %s", msg.topic) - if CONF_COLOR_TEMP_TEMPLATE in self._config: - try: - color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( - msg.payload + color_temp_value = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not color_temp_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty color temperature value", + msg.topic, ) - self._attr_color_temp_kelvin = ( - int(color_temp) - if self._color_temp_kelvin - else color_util.color_temperature_mired_to_kelvin(int(color_temp)) - if color_temp != "None" - else None - ) - except ValueError: - _LOGGER.warning("Invalid color temperature value received") + else: + try: + self._attr_color_temp_kelvin = ( + int(color_temp_value) + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( + int(color_temp_value) + ) + if color_temp_value != "None" + else None + ) + except ValueError: + _LOGGER.warning( + "Invalid color temperature value '%s' received from %s", + color_temp_value, + msg.topic, + ) if ( CONF_RED_TEMPLATE in self._config and CONF_GREEN_TEMPLATE in self._config and CONF_BLUE_TEMPLATE in self._config ): - try: - red = self._value_templates[CONF_RED_TEMPLATE](msg.payload) - green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload) - blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload) - if red == "None" and green == "None" and blue == "None": - self._attr_hs_color = None - else: - self._attr_hs_color = color_util.color_RGB_to_hs( - int(red), int(green), int(blue) - ) + red_value = self._value_templates[CONF_RED_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + green_value = self._value_templates[CONF_GREEN_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + blue_value = self._value_templates[CONF_BLUE_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not red_value or not green_value or not blue_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty color value", msg.topic + ) + elif red_value == "None" and green_value == "None" and blue_value == "None": + self._attr_hs_color = None self._update_color_mode() - except ValueError: - _LOGGER.warning("Invalid color value received") + else: + try: + self._attr_hs_color = color_util.color_RGB_to_hs( + int(red_value), int(green_value), int(blue_value) + ) + self._update_color_mode() + except ValueError: + _LOGGER.warning("Invalid color value received from %s", msg.topic) if CONF_EFFECT_TEMPLATE in self._config: - effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload)) - if ( - effect_list := self._config[CONF_EFFECT_LIST] - ) and effect in effect_list: - self._attr_effect = effect + effect_value = self._value_templates[CONF_EFFECT_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not effect_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty effect value", msg.topic + ) + elif (effect_list := self._config[CONF_EFFECT_LIST]) and str( + effect_value + ) in effect_list: + self._attr_effect = str(effect_value) else: - _LOGGER.warning("Unsupported effect value received") + _LOGGER.warning( + "Unsupported effect value '%s' received from %s", + effect_value, + msg.topic, + ) @callback def _prepare_subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index bcfe94bbd58..8a42797b0f2 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -420,6 +420,12 @@ class MqttComponentConfig: discovery_payload: MQTTDiscoveryPayload +class DeviceMqttOptions(TypedDict, total=False): + """Hold the shared MQTT specific options for an MQTT device.""" + + qos: int + + class MqttDeviceData(TypedDict, total=False): """Hold the data for an MQTT device.""" @@ -430,6 +436,7 @@ class MqttDeviceData(TypedDict, total=False): hw_version: str model: str model_id: str + mqtt_settings: DeviceMqttOptions class MqttAvailabilityData(TypedDict, total=False): diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 4d67b0d56e6..b27ef68368a 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -41,7 +41,15 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_OPTIONS, CONF_STATE_TOPIC, DOMAIN, PAYLOAD_NONE +from .const import ( + CONF_EXPIRE_AFTER, + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_OPTIONS, + CONF_STATE_TOPIC, + CONF_SUGGESTED_DISPLAY_PRECISION, + DOMAIN, + PAYLOAD_NONE, +) from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -51,10 +59,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -CONF_EXPIRE_AFTER = "expire_after" -CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" -CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" - MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( { sensor.ATTR_LAST_RESET, diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index f0112097f4e..cedf120def1 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -126,7 +126,7 @@ "payload_not_available": "Payload not available" }, "data_description": { - "availability_topic": "Topic to receive the availabillity payload on", + "availability_topic": "Topic to receive the availability payload on", "availability_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-templates-with-the-mqtt-integration) to render the availability payload received on the availability topic", "payload_available": "The payload that indicates the device is available (defaults to 'online')", "payload_not_available": "The payload that indicates the device is not available (defaults to 'offline')" @@ -150,6 +150,17 @@ "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.", "model": "E.g. 'Cleanmaster Pro'.", "model_id": "E.g. '123NK2PRO'." + }, + "sections": { + "mqtt_settings": { + "name": "MQTT Settings", + "data": { + "qos": "QoS" + }, + "data_description": { + "qos": "The Quality of Service value the device's entities should use." + } + } } }, "summary_menu": { @@ -198,20 +209,66 @@ "component": "Select the entity you want to update." } }, + "entity_platform_config": { + "title": "Configure MQTT device \"{mqtt_device}\"", + "description": "Please configure specific details for {platform} entity \"{entity}\":", + "data": { + "device_class": "Device class", + "state_class": "State class", + "unit_of_measurement": "Unit of measurement", + "options": "Add option" + }, + "data_description": { + "device_class": "The Device class of the {platform} entity. [Learn more.]({url}#device_class)", + "state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)", + "unit_of_measurement": "Defines the unit of measurement of the sensor, if any.", + "options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement." + }, + "sections": { + "advanced_settings": { + "name": "Advanced options", + "data": { + "suggested_display_precision": "Suggested display precision" + }, + "data_description": { + "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)" + } + } + } + }, "mqtt_platform_config": { "title": "Configure MQTT device \"{mqtt_device}\"", "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":", "data": { "command_topic": "Command topic", "command_template": "Command template", - "retain": "Retain", - "qos": "QoS" + "state_topic": "State topic", + "value_template": "Value template", + "last_reset_value_template": "Last reset value template", + "force_update": "Force update", + "optimistic": "Optimistic", + "retain": "Retain" }, "data_description": { - "command_topic": "The publishing topic that will be used to control the {platform} entity.", + "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic.", - "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", - "qos": "The QoS value {platform} entity should use." + "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", + "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value.", + "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", + "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", + "optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)", + "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker." + }, + "sections": { + "advanced_settings": { + "name": "Advanced settings", + "data": { + "expire_after": "Expire after" + }, + "data_description": { + "expire_after": "If set, it defines the number of seconds after the sensor’s state expires, if it’s not updated. After expiry, the sensor’s state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)" + } + } } } }, @@ -225,7 +282,12 @@ "invalid_input": "Invalid value", "invalid_subscribe_topic": "Invalid subscribe topic", "invalid_template": "Invalid template", - "invalid_url": "Invalid URL" + "invalid_uom": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected device class, please either remove the device class, select a device class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list", + "invalid_url": "Invalid URL", + "options_not_allowed_with_state_class_or_uom": "The 'Options' setting is not allowed when state class or unit of measurement are used", + "options_device_class_enum": "The 'Options' setting must be used with the Enumeration device class. If you continue, the existing options will be reset", + "options_with_enum_device_class": "Configure options for the enumeration sensor", + "uom_required_for_device_class": "The selected device class requires a unit" } } }, @@ -342,9 +404,77 @@ } }, "selector": { + "device_class_sensor": { + "options": { + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "date": "[%key:component::sensor::entity_component::date::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "enum": "Enumeration", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "ph": "[%key:component::sensor::entity_component::ph::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "device_class_switch": { + "options": { + "outlet": "[%key:component::switch::entity_component::outlet::name%]", + "switch": "[%key:component::switch::title%]" + } + }, "platform": { "options": { - "notify": "Notify" + "notify": "Notify", + "sensor": "Sensor", + "switch": "Switch" } }, "set_ca_cert": { @@ -353,6 +483,13 @@ "auto": "Auto", "custom": "Custom" } + }, + "state_class": { + "options": { + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } } }, "services": { diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index c4916b5010c..145f0a2562c 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -26,7 +26,7 @@ from . import subscription from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON from .entity import MqttEntity, async_setup_entity_entry_helper -from .models import MqttValueTemplate, ReceiveMessage +from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic @@ -136,7 +136,18 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): @callback def _handle_state_message_received(self, msg: ReceiveMessage) -> None: """Handle receiving state message via MQTT.""" - payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) + payload = self._templates[CONF_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + + if payload is PayloadSentinel.DEFAULT: + _LOGGER.warning( + "Unable to process payload '%s' for topic %s, with value template '%s'", + msg.payload, + msg.topic, + self._config.get(CONF_VALUE_TEMPLATE), + ) + return if not payload or payload == PAYLOAD_EMPTY_JSON: _LOGGER.debug( diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 27bdb4f2a35..e3996c80a8a 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -411,3 +411,9 @@ def migrate_certificate_file_to_content(file_name_or_auto: str) -> str | None: return certificate_file.read() except OSError: return None + + +@callback +def learn_more_url(platform: str) -> str: + """Return the URL for the platform specific MQTT documentation.""" + return f"https://www.home-assistant.io/integrations/{platform}.mqtt/" diff --git a/homeassistant/components/mullvad/config_flow.py b/homeassistant/components/mullvad/config_flow.py index c16f8879a7b..b179c5605ef 100644 --- a/homeassistant/components/mullvad/config_flow.py +++ b/homeassistant/components/mullvad/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Mullvad VPN integration.""" +import logging from typing import Any from mullvad_api import MullvadAPI, MullvadAPIError @@ -8,6 +9,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class MullvadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Mullvad VPN.""" @@ -24,7 +27,8 @@ class MullvadConfigFlow(ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job(MullvadAPI) except MullvadAPIError: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry(title="Mullvad VPN", data=user_input) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index fb8bb9c3ac2..28e8587e90c 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.1.1"], + "requirements": ["music-assistant-client==1.2.0"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 56bde7bbae7..08176307829 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -94,6 +94,12 @@ SUPPORTED_FEATURES_BASE = ( | MediaPlayerEntityFeature.MEDIA_ENQUEUE | MediaPlayerEntityFeature.MEDIA_ANNOUNCE | MediaPlayerEntityFeature.SEEK + # we always add pause support, + # regardless if the underlying player actually natively supports pause + # because the MA behavior is to internally handle pause with stop + # (and a resume position) and we'd like to keep the UX consistent + # background info: https://github.com/home-assistant/core/issues/140118 + | MediaPlayerEntityFeature.PAUSE ) QUEUE_OPTION_MAP = { @@ -145,6 +151,8 @@ async def async_setup_entry( assert event.object_id is not None if event.object_id in added_ids: return + if not player.expose_to_ha: + return added_ids.add(event.object_id) async_add_entities([MusicAssistantPlayer(mass, event.object_id)]) @@ -153,6 +161,8 @@ async def async_setup_entry( mass_players = [] # add all current players for player in mass.players: + if not player.expose_to_ha: + continue added_ids.add(player.player_id) mass_players.append(MusicAssistantPlayer(mass, player.player_id)) @@ -586,17 +596,24 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): def _update_media_image_url( self, player: Player, queue: PlayerQueue | None ) -> None: - """Update image URL for the active queue item.""" - if queue is None or queue.current_item is None: - self._attr_media_image_url = None - return - if image_url := self.mass.get_media_item_image_url(queue.current_item): + """Update image URL.""" + if queue and queue.current_item: + # image_url is provided by an music-assistant queue + image_url = self.mass.get_media_item_image_url(queue.current_item) + elif player.current_media and player.current_media.image_url: + # image_url is provided by an external source + image_url = player.current_media.image_url + else: + image_url = None + + # check if the image is provided via music-assistant and therefore + # not accessible from the outside + if image_url: self._attr_media_image_remotely_accessible = ( self.mass.server_url not in image_url ) - self._attr_media_image_url = image_url - return - self._attr_media_image_url = None + + self._attr_media_image_url = image_url def _update_media_attributes( self, player: Player, queue: PlayerQueue | None @@ -697,8 +714,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): supported_features = SUPPORTED_FEATURES_BASE if PlayerFeature.SET_MEMBERS in self.player.supported_features: supported_features |= MediaPlayerEntityFeature.GROUPING - if PlayerFeature.PAUSE in self.player.supported_features: - supported_features |= MediaPlayerEntityFeature.PAUSE if self.player.mute_control != PLAYER_CONTROL_NONE: supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE if self.player.volume_control != PLAYER_CONTROL_NONE: diff --git a/homeassistant/components/mutesync/config_flow.py b/homeassistant/components/mutesync/config_flow.py index ef03df39968..a2aacfc927e 100644 --- a/homeassistant/components/mutesync/config_flow.py +++ b/homeassistant/components/mutesync/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from typing import Any import aiohttp @@ -16,6 +17,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required("host"): str}) @@ -60,7 +63,8 @@ class MuteSyncConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry( diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 3a7101e6b39..3793bed8af2 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -102,6 +102,7 @@ SENSORS: dict[str, SensorEntityDescription] = { native_unit_of_measurement=DEGREE, icon="mdi:compass", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), "V_WEIGHT": SensorEntityDescription( key="V_WEIGHT", diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index 30fe5f46d6b..1636cb076cc 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -21,16 +21,16 @@ "device": "IP address of the gateway", "tcp_port": "[%key:common::config_flow::data::port%]", "version": "MySensors version", - "persistence_file": "persistence file (leave empty to auto-generate)" + "persistence_file": "Persistence file (leave empty to auto-generate)" } }, "gw_serial": { "description": "Serial gateway setup", "data": { "device": "Serial port", - "baud_rate": "baud rate", + "baud_rate": "Baud rate", "version": "[%key:component::mysensors::config::step::gw_tcp::data::version%]", - "persistence_file": "Persistence file (leave empty to auto-generate)" + "persistence_file": "[%key:component::mysensors::config::step::gw_tcp::data::persistence_file%]" } }, "gw_mqtt": { @@ -40,7 +40,7 @@ "topic_in_prefix": "Prefix for input topics (topic_in_prefix)", "topic_out_prefix": "Prefix for output topics (topic_out_prefix)", "version": "[%key:component::mysensors::config::step::gw_tcp::data::version%]", - "persistence_file": "[%key:component::mysensors::config::step::gw_serial::data::persistence_file%]" + "persistence_file": "[%key:component::mysensors::config::step::gw_tcp::data::persistence_file%]" } } }, diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 6b4ca6ff324..d297443c059 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from aiohttp.client_exceptions import ClientConnectorError, ClientError +from aiohttp.client_exceptions import ClientError from nettigo_air_monitor import ( ApiError, AuthFailedError, @@ -38,15 +38,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: options = ConnectionOptions(host=host, username=username, password=password) try: nam = await NettigoAirMonitor.create(websession, options) - except (ApiError, ClientError, ClientConnectorError, TimeoutError) as err: - raise ConfigEntryNotReady from err + except (ApiError, ClientError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err try: await nam.async_check_credentials() - except ApiError as err: - raise ConfigEntryNotReady from err + except (ApiError, ClientError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err except AuthFailedError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"device": entry.title}, + ) from err coordinator = NAMDataUpdateCoordinator(hass, entry, nam) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py index 60145e4fe27..791a5fdc27c 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -4,6 +4,9 @@ from __future__ import annotations import logging +from aiohttp.client_exceptions import ClientError +from nettigo_air_monitor import ApiError, AuthFailedError + from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, @@ -11,9 +14,11 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import NAMConfigEntry, NAMDataUpdateCoordinator PARALLEL_UPDATES = 1 @@ -59,4 +64,16 @@ class NAMButton(CoordinatorEntity[NAMDataUpdateCoordinator], ButtonEntity): async def async_press(self) -> None: """Triggers the restart.""" - await self.coordinator.nam.async_restart() + try: + await self.coordinator.nam.async_restart() + except (ApiError, ClientError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.config_entry.title, + }, + ) from err + except AuthFailedError: + self.coordinator.config_entry.async_start_reauth(self.hass) diff --git a/homeassistant/components/nam/coordinator.py b/homeassistant/components/nam/coordinator.py index 3e2c9c24474..8a898dee378 100644 --- a/homeassistant/components/nam/coordinator.py +++ b/homeassistant/components/nam/coordinator.py @@ -64,6 +64,10 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): # We do not need to catch AuthFailed exception here because sensor data is # always available without authorization. except (ApiError, InvalidSensorDataError, RetryError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.config_entry.title}, + ) from error return data diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index be9fb1fbb07..b02eecaa41e 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -96,20 +96,20 @@ "pmsx003_caqi_level": { "name": "PMSx003 common air quality index level", "state": { - "very_low": "Very low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very high" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -129,20 +129,20 @@ "sds011_caqi_level": { "name": "SDS011 common air quality index level", "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -165,20 +165,20 @@ "sps30_caqi_level": { "name": "SPS30 common air quality index level", "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -205,5 +205,19 @@ "name": "Last restart" } } + }, + "exceptions": { + "auth_error": { + "message": "Authentication failed for {device}, please update your credentials" + }, + "device_communication_error": { + "message": "An error occurred while communicating with {device}" + }, + "device_communication_action_error": { + "message": "An error occurred while calling action for {entity} for {device}" + }, + "update_error": { + "message": "An error occurred while retrieving data from {device}" + } } } diff --git a/homeassistant/components/nasweb/config_flow.py b/homeassistant/components/nasweb/config_flow.py index 3a9ad3f7d49..298210903dc 100644 --- a/homeassistant/components/nasweb/config_flow.py +++ b/homeassistant/components/nasweb/config_flow.py @@ -103,7 +103,7 @@ class NASwebConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "missing_status" except AbortFlow: raise - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/neff/__init__.py b/homeassistant/components/neff/__init__.py new file mode 100644 index 00000000000..211ce088834 --- /dev/null +++ b/homeassistant/components/neff/__init__.py @@ -0,0 +1 @@ +"""Neff virtual integration.""" diff --git a/homeassistant/components/neff/manifest.json b/homeassistant/components/neff/manifest.json new file mode 100644 index 00000000000..1dfc91f94c9 --- /dev/null +++ b/homeassistant/components/neff/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "neff", + "name": "Neff", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index 146b6f2479e..a3d2901e911 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -20,8 +20,10 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass +import datetime import logging import os +import pathlib from typing import Any from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait @@ -46,6 +48,7 @@ from homeassistant.components.media_source import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.storage import Store from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.util import dt as dt_util @@ -72,6 +75,9 @@ MEDIA_PATH = f"{DOMAIN}/event_media" # Size of small in-memory disk cache to avoid excessive disk reads DISK_READ_LRU_MAX_SIZE = 32 +# Remove orphaned media files that are older than this age +ORPHANED_MEDIA_AGE_CUTOFF = datetime.timedelta(days=7) + async def async_get_media_event_store( hass: HomeAssistant, subscriber: GoogleNestSubscriber @@ -123,6 +129,12 @@ class NestEventMediaStore(EventMediaStore): self._media_path = media_path self._data: dict[str, Any] | None = None self._devices: Mapping[str, str] | None = {} + # Invoke garbage collection for orphaned files one per + async_track_time_interval( + hass, + self.async_remove_orphaned_media, + datetime.timedelta(days=1), + ) async def async_load(self) -> dict | None: """Load data.""" @@ -249,6 +261,68 @@ class NestEventMediaStore(EventMediaStore): devices[device.name] = device_entry.id return devices + async def async_remove_orphaned_media(self, now: datetime.datetime) -> None: + """Remove any media files that are orphaned and not referenced by the active event data. + + The event media store handles garbage collection, but there may be cases where files are + left around or unable to be removed. This is a scheduled event that will also check for + old orphaned files and remove them when the events are not referenced in the active list + of event data. + + Event media files are stored with the format -.suffix. We extract + the list of valid timestamps from the event data and remove any files that are not in that list + or are older than the cutoff time. + """ + _LOGGER.debug("Checking for orphaned media at %s", now) + + def _cleanup(event_timestamps: dict[str, set[int]]) -> None: + time_cutoff = (now - ORPHANED_MEDIA_AGE_CUTOFF).timestamp() + media_path = pathlib.Path(self._media_path) + for device_id, valid_timestamps in event_timestamps.items(): + media_files = list(media_path.glob(f"{device_id}/*")) + _LOGGER.debug("Found %d files (device=%s)", len(media_files), device_id) + for media_file in media_files: + if "-" not in media_file.name: + continue + try: + timestamp = int(media_file.name.split("-")[0]) + except ValueError: + continue + if timestamp in valid_timestamps or timestamp > time_cutoff: + continue + _LOGGER.debug("Removing orphaned media file: %s", media_file) + try: + os.remove(media_file) + except OSError as err: + _LOGGER.error( + "Unable to remove orphaned media file: %s %s", + media_file, + err, + ) + + # Nest device id mapped to home assistant device id + event_timestamps = await self._get_valid_event_timestamps() + await self._hass.async_add_executor_job(_cleanup, event_timestamps) + + async def _get_valid_event_timestamps(self) -> dict[str, set[int]]: + """Return a mapping of home assistant device id to valid timestamps.""" + device_map = await self._get_devices() + event_data = await self.async_load() or {} + valid_device_timestamps = {} + for nest_device_id, device_id in device_map.items(): + if (device_events := event_data.get(nest_device_id, {})) is None: + continue + valid_device_timestamps[device_id] = { + int( + datetime.datetime.fromisoformat( + camera_event["timestamp"] + ).timestamp() + ) + for events in device_events + for camera_event in events["events"].values() + } + return valid_device_timestamps + async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up Nest media source.""" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 5f8084d542c..56b8233912f 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -213,7 +213,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="wind_angle", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), NetatmoSensorEntityDescription( key="windstrength", @@ -235,7 +236,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="gust_angle", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), NetatmoSensorEntityDescription( key="guststrength", @@ -345,7 +347,8 @@ PUBLIC_WEATHER_STATION_TYPES: tuple[ key="windangle_value", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, value_fn=lambda area: area.get_latest_wind_angles(), ), NetatmoPublicWeatherSensorEntityDescription( @@ -360,7 +363,8 @@ PUBLIC_WEATHER_STATION_TYPES: tuple[ translation_key="gust_angle", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, value_fn=lambda area: area.get_latest_gust_angles(), ), NetatmoPublicWeatherSensorEntityDescription( diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 200cce86997..14c7dc55cf0 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -4,12 +4,14 @@ from __future__ import annotations from ipaddress import IPv4Address, IPv6Address, ip_interface import logging +from pathlib import Path from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.loader import bind_hass +from homeassistant.util import package from . import util from .const import ( @@ -27,6 +29,19 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +def _check_docker_without_host_networking() -> bool: + """Check if we are not using host networking in Docker.""" + if not package.is_docker_env(): + # We are not in Docker, so we don't need to check for host networking + return True + + if Path("/proc/sys/net/ipv4/ip_forward").exists(): + # If we can read this file, we likely have host networking + return True + + return False + + @bind_hass async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: """Get the network adapter configuration.""" @@ -166,5 +181,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_get_network(hass) + if not await hass.async_add_executor_job(_check_docker_without_host_networking): + docs_url = "https://docs.docker.com/network/network-tutorial-host/" + install_url = "https://www.home-assistant.io/installation/linux#install-home-assistant-container" + ir.async_create_issue( + hass, + DOMAIN, + "docker_host_network", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="docker_host_network", + learn_more_url=install_url, + translation_placeholders={"docs_url": docs_url, "install_url": install_url}, + ) + async_register_websocket_commands(hass) return True diff --git a/homeassistant/components/network/strings.json b/homeassistant/components/network/strings.json index 6aca7343221..3e135fff60b 100644 --- a/homeassistant/components/network/strings.json +++ b/homeassistant/components/network/strings.json @@ -6,5 +6,11 @@ "ipv6_addresses": "IPv6 addresses", "announce_addresses": "Announce addresses" } + }, + "issues": { + "docker_host_network": { + "title": "Home Assistant is not using host networking", + "description": "Home Assistant is running in a container without host networking mode. This can cause networking issues with device discovery, multicast, broadcast, other network features, and incorrectly detecting its own URL and IP addresses, causing issues with media players and sending audio responses to voice assistants.\n\nIt is recommended to run Home Assistant with host networking by adding the `--network host` flag to your Docker run command or setting `network_mode: host` in your `docker-compose.yml` file.\n\nSee the [Docker documentation]({docs_url}) for more information about Docker host networking and refer to the [Home Assistant installation guide]({install_url}) for our recommended and supported setup." + } } } diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index e9de81cca7c..e9637a16ae0 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -53,13 +53,18 @@ PARALLEL_UPDATES = 1 # keep data in sync with only one connection at a time SERVICE_SET_AIRCLEANER_MODE = "set_aircleaner_mode" SERVICE_SET_HUMIDIFY_SETPOINT = "set_humidify_setpoint" +SERVICE_SET_DEHUMIDIFY_SETPOINT = "set_dehumidify_setpoint" SERVICE_SET_HVAC_RUN_MODE = "set_hvac_run_mode" SET_AIRCLEANER_SCHEMA: VolDictType = { vol.Required(ATTR_AIRCLEANER_MODE): cv.string, } -SET_HUMIDITY_SCHEMA: VolDictType = { +SET_HUMIDIFY_SCHEMA: VolDictType = { + vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=10, max=45)), +} + +SET_DEHUMIDIFY_SCHEMA: VolDictType = { vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=35, max=65)), } @@ -126,9 +131,14 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_SET_HUMIDIFY_SETPOINT, - SET_HUMIDITY_SCHEMA, + SET_HUMIDIFY_SCHEMA, f"async_{SERVICE_SET_HUMIDIFY_SETPOINT}", ) + platform.async_register_entity_service( + SERVICE_SET_DEHUMIDIFY_SETPOINT, + SET_DEHUMIDIFY_SCHEMA, + f"async_{SERVICE_SET_DEHUMIDIFY_SETPOINT}", + ) platform.async_register_entity_service( SERVICE_SET_AIRCLEANER_MODE, SET_AIRCLEANER_SCHEMA, @@ -224,20 +234,48 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): return self._zone.get_preset() async def async_set_humidity(self, humidity: int) -> None: - """Dehumidify target.""" - if self._thermostat.has_dehumidify_support(): - await self.async_set_dehumidify_setpoint(humidity) + """Set humidity targets. + + HA doesn't support separate humidify and dehumidify targets. + Set the target for the current mode if in [heat, cool] + otherwise set both targets to the clamped values. + """ + zone_current_mode = self._zone.get_current_mode() + if zone_current_mode == OPERATION_MODE_HEAT: + if self._thermostat.has_humidify_support(): + await self.async_set_humidify_setpoint(humidity) + elif zone_current_mode == OPERATION_MODE_COOL: + if self._thermostat.has_dehumidify_support(): + await self.async_set_dehumidify_setpoint(humidity) else: - await self.async_set_humidify_setpoint(humidity) + if self._thermostat.has_humidify_support(): + await self.async_set_humidify_setpoint(humidity) + if self._thermostat.has_dehumidify_support(): + await self.async_set_dehumidify_setpoint(humidity) self._signal_thermostat_update() @property - def target_humidity(self): - """Humidity indoors setpoint.""" + def target_humidity(self) -> float | None: + """Humidity indoors setpoint. + + In systems that support both humidification and dehumidification, + two values for target exist. We must choose one to return. + + :return: The target humidity setpoint. + """ + + # If heat is on, always return humidify value first + if ( + self._has_humidify_support + and self._zone.get_current_mode() == OPERATION_MODE_HEAT + ): + return percent_conv(self._thermostat.get_humidify_setpoint()) + # Fall back to previous behavior of returning dehumidify value then humidify if self._has_dehumidify_support: return percent_conv(self._thermostat.get_dehumidify_setpoint()) if self._has_humidify_support: return percent_conv(self._thermostat.get_humidify_setpoint()) + return None @property diff --git a/homeassistant/components/nexia/icons.json b/homeassistant/components/nexia/icons.json index a2157f5c035..c9434a332df 100644 --- a/homeassistant/components/nexia/icons.json +++ b/homeassistant/components/nexia/icons.json @@ -26,6 +26,9 @@ "set_humidify_setpoint": { "service": "mdi:water-percent" }, + "set_dehumidify_setpoint": { + "service": "mdi:water-percent" + }, "set_hvac_run_mode": { "service": "mdi:hvac" } diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index 293a9308cb4..648b5dc3eeb 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -114,6 +114,35 @@ async def async_setup_entry( percent_conv, ) ) + # Heating Humidification Setpoint + if thermostat.has_humidify_support(): + entities.append( + NexiaThermostatSensor( + coordinator, + thermostat, + "get_humidify_setpoint", + "get_humidify_setpoint", + SensorDeviceClass.HUMIDITY, + PERCENTAGE, + SensorStateClass.MEASUREMENT, + percent_conv, + ) + ) + + # Cooling Dehumidification Setpoint + if thermostat.has_dehumidify_support(): + entities.append( + NexiaThermostatSensor( + coordinator, + thermostat, + "get_dehumidify_setpoint", + "get_dehumidify_setpoint", + SensorDeviceClass.HUMIDITY, + PERCENTAGE, + SensorStateClass.MEASUREMENT, + percent_conv, + ) + ) # Zone Sensors for zone_id in thermostat.get_zone_ids(): diff --git a/homeassistant/components/nexia/services.yaml b/homeassistant/components/nexia/services.yaml index ede1f311acf..d010676d14a 100644 --- a/homeassistant/components/nexia/services.yaml +++ b/homeassistant/components/nexia/services.yaml @@ -14,6 +14,20 @@ set_aircleaner_mode: - "quick" set_humidify_setpoint: + target: + entity: + integration: nexia + domain: climate + fields: + humidity: + required: true + selector: + number: + min: 10 + max: 45 + unit_of_measurement: "%" + +set_dehumidify_setpoint: target: entity: integration: nexia diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index 43da2cf05c7..f6b08d5e8e5 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -53,6 +53,12 @@ }, "zone_setpoint_status": { "name": "Zone setpoint status" + }, + "get_humidify_setpoint": { + "name": "Heating humidify setpoint" + }, + "get_dehumidify_setpoint": { + "name": "Cooling dehumidify setpoint" } }, "switch": { @@ -76,12 +82,22 @@ } }, "set_humidify_setpoint": { - "name": "Set humidify set point", - "description": "Sets the target humidity.", + "name": "Set humidify setpoint", + "description": "Sets the target humidity for heating.", "fields": { "humidity": { "name": "Humidity", - "description": "The humidification setpoint." + "description": "The setpoint for humidification when heating." + } + } + }, + "set_dehumidify_setpoint": { + "name": "Set dehumidify setpoint", + "description": "Sets the target humidity for cooling.", + "fields": { + "humidity": { + "name": "Humidity", + "description": "The setpoint for dehumidification when cooling." } } }, diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index d3327c4c08b..d36064d8fb0 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from aiohttp.client_exceptions import ClientConnectorError @@ -19,6 +20,8 @@ from .const import CONF_PROFILE_ID, DOMAIN AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) +_LOGGER = logging.getLogger(__name__) + async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns: """Check if credentials are valid.""" @@ -51,7 +54,8 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_api_key" except (ApiError, ClientConnectorError, RetryError, TimeoutError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return await self.async_step_profiles() @@ -111,7 +115,8 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_api_key" except (ApiError, ClientConnectorError, RetryError, TimeoutError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_update_reload_and_abort( diff --git a/homeassistant/components/niko_home_control/config_flow.py b/homeassistant/components/niko_home_control/config_flow.py index f37e5e9248a..76e71bc1690 100644 --- a/homeassistant/components/niko_home_control/config_flow.py +++ b/homeassistant/components/niko_home_control/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from nhc.controller import NHCController @@ -12,6 +13,8 @@ from homeassistant.const import CONF_HOST from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -25,7 +28,8 @@ async def test_connection(host: str) -> str | None: controller = NHCController(host, 8000) try: await controller.connect() - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return "cannot_connect" return None diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index 45212c0220b..8bb9a347373 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["PyNINA==0.3.4"], + "requirements": ["PyNINA==0.3.5"], "single_config_entry": true } diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 822b0236dd0..3552ac3c26d 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -360,7 +360,7 @@ class NMBSSensor(SensorEntity): attrs[ATTR_LONGITUDE] = self.station_coordinates[1] if self.is_via_connection and not self._excl_vias: - via = self._attrs.vias.via[0] + via = self._attrs.vias[0] attrs["via"] = via.station attrs["via_arrival_platform"] = via.arrival.platform diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index 28be01862e9..5d1b8350edf 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -44,16 +44,16 @@ "entity": { "select": { "global_override": { - "name": "global override", + "name": "Global override", "state": { - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" } }, "week_profile": { - "name": "week profile" + "name": "Week profile" } } } diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py index c6993826239..4bde12afc3c 100644 --- a/homeassistant/components/nordpool/sensor.py +++ b/homeassistant/components/nordpool/sensor.py @@ -34,7 +34,7 @@ def validate_prices( index: int, ) -> float | None: """Validate and return.""" - if result := func(entity)[area][index]: + if (result := func(entity)[area][index]) is not None: return result / 1000 return None diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index daf47bc7de1..84e66c3db96 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -48,8 +48,8 @@ "state_attributes": { "battery_critical": { "state": { - "on": "[%key:component::binary_sensor::entity_component::battery::state::on%]", - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]" + "on": "[%key:common::state::low%]", + "off": "[%key:common::state::normal%]" } } } diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 169dbbbff5d..9e1e77a2aaf 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -23,14 +23,10 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - INTEGRATION_SUPPORTED_COMMANDS, - PLATFORMS, -) +from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS NUT_FAKE_SERIAL = ["unknown", "blank"] @@ -68,7 +64,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: alias = config.get(CONF_ALIAS) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + if CONF_SCAN_INTERVAL in entry.options: + current_options = {**entry.options} + current_options.pop(CONF_SCAN_INTERVAL) + hass.config_entries.async_update_entry(entry, options=current_options) data = PyNUTData(host, port, alias, username, password) @@ -79,9 +78,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: try: return await data.async_update() except NUTLoginError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "err": str(err), + }, + ) from err except NUTError as err: - raise UpdateFailed(f"Error fetching UPS state: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="data_fetch_error", + translation_placeholders={ + "err": str(err), + }, + ) from err coordinator = DataUpdateCoordinator( hass, @@ -89,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: config_entry=entry, name="NUT resource status", update_method=async_update_data, - update_interval=timedelta(seconds=scan_interval), + update_interval=timedelta(seconds=60), always_update=False, ) @@ -103,30 +114,58 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: ) status = coordinator.data - _LOGGER.debug("NUT Sensors Available: %s", status) + _LOGGER.debug("NUT Sensors Available: %s", status if status else None) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) unique_id = _unique_id_from_status(status) if unique_id is None: unique_id = entry.entry_id + elif entry.unique_id is None: + hass.config_entries.async_update_entry(entry, unique_id=unique_id) + if username is not None and password is not None: + # Dynamically add outlet integration commands + additional_integration_commands = set() + if (num_outlets := status.get("outlet.count")) is not None: + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str: str = str(outlet_num) + additional_integration_commands |= { + f"outlet.{outlet_num_str}.load.cycle", + f"outlet.{outlet_num_str}.load.on", + f"outlet.{outlet_num_str}.load.off", + } + + valid_integration_commands = ( + INTEGRATION_SUPPORTED_COMMANDS | additional_integration_commands + ) + user_available_commands = { - device_supported_command - for device_supported_command in await data.async_list_commands() or {} - if device_supported_command in INTEGRATION_SUPPORTED_COMMANDS + device_command + for device_command in await data.async_list_commands() or {} + if device_command in valid_integration_commands } else: user_available_commands = set() + _LOGGER.debug( + "NUT Commands Available: %s", + user_available_commands if user_available_commands else None, + ) + entry.runtime_data = NutRuntimeData( coordinator, data, unique_id, user_available_commands ) + connections: set[tuple[str, str]] | None = None + if data.device_info.mac_address is not None: + connections = {(CONNECTION_NETWORK_MAC, data.device_info.mac_address)} + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, unique_id)}, + connections=connections, name=data.name.title(), manufacturer=data.device_info.manufacturer, model=data.device_info.model, @@ -141,12 +180,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) @@ -214,6 +253,7 @@ class NUTDeviceInfo: model_id: str | None = None firmware: str | None = None serial: str | None = None + mac_address: str | None = None device_location: str | None = None @@ -240,6 +280,7 @@ class PyNUTData: self._client = AIONUTClient(self._host, port, username, password, 5, persistent) self.ups_list: dict[str, str] | None = None + self.device_name: str | None = None self._status: dict[str, str] | None = None self._device_info: NUTDeviceInfo | None = None @@ -250,7 +291,7 @@ class PyNUTData: @property def name(self) -> str: - """Return the name of the ups.""" + """Return the name of the NUT device.""" return self._alias or f"Nut-{self._host}" @property @@ -276,9 +317,18 @@ class PyNUTData: model_id: str | None = self._status.get("device.part") firmware = _firmware_from_status(self._status) serial = _serial_from_status(self._status) + mac_address: str | None = self._status.get("device.macaddr") + if mac_address is not None: + mac_address = format_mac(mac_address.rstrip().replace(" ", ":")) device_location: str | None = self._status.get("device.location") return NUTDeviceInfo( - manufacturer, model, model_id, firmware, serial, device_location + manufacturer, + model, + model_id, + firmware, + serial, + mac_address, + device_location, ) async def _async_get_status(self) -> dict[str, str]: @@ -294,6 +344,8 @@ class PyNUTData: self._status = await self._async_get_status() if self._device_info is None: self._device_info = self._get_device_info() + if self.device_name is None: + self.device_name = self.name.title() return self._status async def async_run_command(self, command_name: str) -> None: @@ -305,7 +357,12 @@ class PyNUTData: await self._client.run_command(self._alias, command_name) except NUTError as err: raise HomeAssistantError( - f"Error running command {command_name}, {err}" + translation_domain=DOMAIN, + translation_key="nut_command_error", + translation_placeholders={ + "command_name": command_name, + "err": str(err), + }, ) from err async def async_list_commands(self) -> set[str] | None: diff --git a/homeassistant/components/nut/button.py b/homeassistant/components/nut/button.py new file mode 100644 index 00000000000..0708056b2e3 --- /dev/null +++ b/homeassistant/components/nut/button.py @@ -0,0 +1,67 @@ +"""Provides a switch for switchable NUT outlets.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NutConfigEntry +from .entity import NUTBaseEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NutConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the NUT buttons.""" + pynut_data = config_entry.runtime_data + coordinator = pynut_data.coordinator + status = coordinator.data + + # Dynamically add outlet button types + if (num_outlets := status.get("outlet.count")) is None: + return + + data = pynut_data.data + unique_id = pynut_data.unique_id + valid_button_types: dict[str, ButtonEntityDescription] = {} + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str = str(outlet_num) + outlet_name: str = status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str + valid_button_types |= { + f"outlet.{outlet_num_str}.load.cycle": ButtonEntityDescription( + key=f"outlet.{outlet_num_str}.load.cycle", + translation_key="outlet_number_load_cycle", + translation_placeholders={"outlet_name": outlet_name}, + device_class=ButtonDeviceClass.RESTART, + entity_registry_enabled_default=True, + ), + } + + async_add_entities( + NUTButton(coordinator, description, data, unique_id) + for button_id, description in valid_button_types.items() + if button_id in pynut_data.user_available_commands + ) + + +class NUTButton(NUTBaseEntity, ButtonEntity): + """Representation of a button entity for NUT.""" + + async def async_press(self) -> None: + """Press the button.""" + name_list = self.entity_description.key.split(".") + command_name = f"{name_list[0]}.{name_list[1]}.load.cycle" + await self.pynut_data.async_run_command(command_name) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index b1b44966d14..a69d898ff6c 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -4,45 +4,50 @@ from __future__ import annotations from collections.abc import Mapping import logging +from types import MappingProxyType from typing import Any from aionut import NUTError, NUTLoginError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_ALIAS, CONF_BASE, CONF_HOST, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import PyNUTData -from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN +from . import PyNUTData, _unique_id_from_status +from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) -AUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} +REAUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} + +PASSWORD_NOT_CHANGED = "__**password_not_changed**__" -def _base_schema(nut_config: dict[str, Any]) -> vol.Schema: +def _base_schema( + nut_config: dict[str, Any] | MappingProxyType[str, Any], + use_password_not_changed: bool = False, +) -> vol.Schema: """Generate base schema.""" base_schema = { vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str, vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int, + vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str, + vol.Optional( + CONF_PASSWORD, + default=PASSWORD_NOT_CHANGED if use_password_not_changed else "", + ): str, } - base_schema.update(AUTH_SCHEMA) + return vol.Schema(base_schema) @@ -72,6 +77,26 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return {"ups_list": nut_data.ups_list, "available_resources": status} +def _check_host_port_alias_match( + first: Mapping[str, Any], second: Mapping[str, Any] +) -> bool: + """Check if first and second have the same host, port and alias.""" + + if first[CONF_HOST] != second[CONF_HOST] or first[CONF_PORT] != second[CONF_PORT]: + return False + + first_alias = first.get(CONF_ALIAS) + second_alias = second.get(CONF_ALIAS) + if (first_alias is None and second_alias is None) or ( + first_alias is not None + and second_alias is not None + and first_alias == second_alias + ): + return True + + return False + + def _format_host_port_alias(user_input: Mapping[str, Any]) -> str: """Format a host, port, and alias so it can be used for comparison or display.""" host = user_input[CONF_HOST] @@ -125,6 +150,11 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): if self._host_port_alias_already_configured(nut_config): return self.async_abort(reason="already_configured") + + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + title = _format_host_port_alias(nut_config) return self.async_create_entry(title=title, data=nut_config) @@ -138,7 +168,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_ups( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the picking the ups.""" + """Handle selecting the NUT device alias.""" errors: dict[str, str] = {} placeholders: dict[str, str] = {} nut_config = self.nut_config @@ -147,8 +177,13 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self.nut_config.update(user_input) if self._host_port_alias_already_configured(nut_config): return self.async_abort(reason="already_configured") - _, errors, placeholders = await self._async_validate_or_error(nut_config) + + info, errors, placeholders = await self._async_validate_or_error(nut_config) if not errors: + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + title = _format_host_port_alias(nut_config) return self.async_create_entry(title=title, data=nut_config) @@ -159,6 +194,99 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders=placeholders, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + nut_config = self.nut_config + + if user_input is not None: + nut_config.update(user_input) + + info, errors, placeholders = await self._async_validate_or_error(nut_config) + + if not errors: + if len(info["ups_list"]) > 1: + self.ups_list = info["ups_list"] + return await self.async_step_reconfigure_ups() + + if not _check_host_port_alias_match( + reconfigure_entry.data, + nut_config, + ) and (self._host_port_alias_already_configured(nut_config)): + return self.async_abort(reason="already_configured") + + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED: + nut_config.pop(CONF_PASSWORD) + + new_title = _format_host_port_alias(nut_config) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + unique_id=unique_id, + title=new_title, + data_updates=nut_config, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=_base_schema( + reconfigure_entry.data, + use_password_not_changed=True, + ), + errors=errors, + description_placeholders=placeholders, + ) + + async def async_step_reconfigure_ups( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle selecting the NUT device alias.""" + + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + nut_config = self.nut_config + + if user_input is not None: + self.nut_config.update(user_input) + + if not _check_host_port_alias_match( + reconfigure_entry.data, + nut_config, + ) and (self._host_port_alias_already_configured(nut_config)): + return self.async_abort(reason="already_configured") + + info, errors, placeholders = await self._async_validate_or_error(nut_config) + if not errors: + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + + if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED: + nut_config.pop(CONF_PASSWORD) + + new_title = _format_host_port_alias(nut_config) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + unique_id=unique_id, + title=new_title, + data_updates=nut_config, + ) + + return self.async_show_form( + step_id="reconfigure_ups", + data_schema=_ups_schema(self.ups_list or {}), + errors=errors, + description_placeholders=placeholders, + ) + def _host_port_alias_already_configured(self, user_input: dict[str, Any]) -> bool: """See if we already have a nut entry matching user input configured.""" existing_host_port_aliases = { @@ -200,6 +328,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reauth input.""" + errors: dict[str, str] = {} existing_entry = self.reauth_entry assert existing_entry @@ -208,6 +337,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: existing_data[CONF_HOST], CONF_PORT: existing_data[CONF_PORT], } + if user_input is not None: new_config = { **existing_data, @@ -225,37 +355,8 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders.update(placeholders) return self.async_show_form( - description_placeholders=description_placeholders, step_id="reauth_confirm", - data_schema=vol.Schema(AUTH_SCHEMA), + data_schema=vol.Schema(REAUTH_SCHEMA), errors=errors, + description_placeholders=description_placeholders, ) - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: - """Get the options flow for this handler.""" - return OptionsFlowHandler() - - -class OptionsFlowHandler(OptionsFlow): - """Handle a option flow for nut.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - scan_interval = self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - - base_schema = { - vol.Optional(CONF_SCAN_INTERVAL, default=scan_interval): vol.All( - vol.Coerce(int), vol.Clamp(min=10, max=300) - ) - } - - return self.async_show_form(step_id="init", data_schema=vol.Schema(base_schema)) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index e67299aa9a3..175e971a12a 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -6,7 +6,11 @@ from homeassistant.const import Platform DOMAIN = "nut" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [ + Platform.BUTTON, + Platform.SENSOR, + Platform.SWITCH, +] DEFAULT_NAME = "NUT UPS" DEFAULT_HOST = "localhost" @@ -15,8 +19,6 @@ DEFAULT_PORT = 3493 KEY_STATUS = "ups.status" KEY_STATUS_DISPLAY = "ups.status.display" -DEFAULT_SCAN_INTERVAL = 60 - STATE_TYPES = { "OL": "Online", "OB": "On Battery", @@ -63,10 +65,6 @@ COMMAND_TEST_FAILURE_STOP = "test.failure.stop" COMMAND_TEST_PANEL_START = "test.panel.start" COMMAND_TEST_PANEL_STOP = "test.panel.stop" COMMAND_TEST_SYSTEM_START = "test.system.start" -COMMAND_OUTLET_1_LOAD_OFF = "outlet.1.load.off" -COMMAND_OUTLET_1_LOAD_ON = "outlet.1.load.on" -COMMAND_OUTLET_2_LOAD_OFF = "outlet.2.load.off" -COMMAND_OUTLET_2_LOAD_ON = "outlet.2.load.on" INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_BEEPER_DISABLE, @@ -95,8 +93,4 @@ INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_TEST_PANEL_START, COMMAND_TEST_PANEL_STOP, COMMAND_TEST_SYSTEM_START, - COMMAND_OUTLET_1_LOAD_OFF, - COMMAND_OUTLET_1_LOAD_ON, - COMMAND_OUTLET_2_LOAD_OFF, - COMMAND_OUTLET_2_LOAD_ON, } diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py index ffaa195deaf..86f7fe5a7e6 100644 --- a/homeassistant/components/nut/device_action.py +++ b/homeassistant/components/nut/device_action.py @@ -51,7 +51,11 @@ async def async_call_action_from_config( runtime_data = _get_runtime_data_from_device_id(hass, device_id) if not runtime_data: raise InvalidDeviceAutomationConfig( - f"Unable to find a NUT device with id {device_id}" + translation_domain=DOMAIN, + translation_key="device_invalid", + translation_placeholders={ + "device_id": device_id, + }, ) await runtime_data.data.async_run_command(command_name) diff --git a/homeassistant/components/nut/entity.py b/homeassistant/components/nut/entity.py new file mode 100644 index 00000000000..e6536d8aad6 --- /dev/null +++ b/homeassistant/components/nut/entity.py @@ -0,0 +1,67 @@ +"""Base entity for the NUT integration.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import cast + +from homeassistant.const import ( + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + ATTR_SW_VERSION, +) +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import PyNUTData +from .const import DOMAIN + +NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { + "manufacturer": ATTR_MANUFACTURER, + "model": ATTR_MODEL, + "firmware": ATTR_SW_VERSION, + "serial": ATTR_SERIAL_NUMBER, +} + + +class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): + """NUT base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator, + entity_description: EntityDescription, + data: PyNUTData, + unique_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_unique_id = f"{unique_id}_{entity_description.key}" + + self.pynut_data = data + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=self.pynut_data.device_name, + ) + self._attr_device_info.update(_get_nut_device_info(data)) + + +def _get_nut_device_info(data: PyNUTData) -> DeviceInfo: + """Return a DeviceInfo object filled with NUT device info.""" + nut_dev_infos = asdict(data.device_info) + nut_infos = { + info_key: nut_dev_infos[nut_key] + for nut_key, info_key in NUT_DEV_INFO_TO_DEV_INFO.items() + if nut_dev_infos[nut_key] is not None + } + + return cast(DeviceInfo, nut_infos) diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index 261d28d712f..a795368005c 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -1,5 +1,10 @@ { "entity": { + "button": { + "outlet_number_load_cycle": { + "default": "mdi:restart" + } + }, "sensor": { "ambient_humidity_status": { "default": "mdi:information-outline" @@ -37,11 +42,26 @@ "battery_packs_bad": { "default": "mdi:information-outline" }, + "battery_runtime": { + "default": "mdi:clock-outline" + }, + "battery_runtime_low": { + "default": "mdi:clock-alert-outline" + }, + "battery_runtime_restart": { + "default": "mdi:clock-start" + }, "battery_type": { "default": "mdi:information-outline" }, + "battery_voltage_high": { + "default": "mdi:battery-high" + }, + "battery_voltage_low": { + "default": "mdi:battery-low" + }, "input_bypass_phases": { - "default": "mdi:information-outline" + "default": "mdi:sine-wave" }, "input_current_status": { "default": "mdi:information-outline" @@ -50,13 +70,10 @@ "default": "mdi:information-outline" }, "input_load": { - "default": "mdi:gauge" + "default": "mdi:percent-box-outline" }, "input_phases": { - "default": "mdi:information-outline" - }, - "input_power": { - "default": "mdi:gauge" + "default": "mdi:sine-wave" }, "input_sensitivity": { "default": "mdi:information-outline" @@ -67,20 +84,23 @@ "input_voltage_status": { "default": "mdi:information-outline" }, - "outlet_voltage": { - "default": "mdi:gauge" + "outlet_number_current_status": { + "default": "mdi:information-outline" + }, + "outlet_number_desc": { + "default": "mdi:information-outline" }, "output_l1_power_percent": { - "default": "mdi:gauge" + "default": "mdi:percent-circle-outline" }, "output_l2_power_percent": { - "default": "mdi:gauge" + "default": "mdi:percent-circle-outline" }, "output_l3_power_percent": { - "default": "mdi:gauge" + "default": "mdi:percent-circle-outline" }, "output_phases": { - "default": "mdi:information-outline" + "default": "mdi:sine-wave" }, "ups_alarm": { "default": "mdi:alarm" @@ -91,20 +111,29 @@ "ups_contacts": { "default": "mdi:information-outline" }, + "ups_delay_reboot": { + "default": "mdi:timelapse" + }, + "ups_delay_shutdown": { + "default": "mdi:timelapse" + }, + "ups_delay_start": { + "default": "mdi:timelapse" + }, "ups_display_language": { "default": "mdi:information-outline" }, "ups_efficiency": { - "default": "mdi:gauge" + "default": "mdi:percent-outline" }, "ups_id": { "default": "mdi:information-outline" }, "ups_load": { - "default": "mdi:gauge" + "default": "mdi:percent-box-outline" }, "ups_load_high": { - "default": "mdi:gauge" + "default": "mdi:percent-box-outline" }, "ups_shutdown": { "default": "mdi:information-outline" @@ -127,15 +156,32 @@ "ups_test_date": { "default": "mdi:calendar" }, + "ups_test_interval": { + "default": "mdi:timelapse" + }, "ups_test_result": { "default": "mdi:information-outline" }, + "ups_timer_reboot": { + "default": "mdi:timer-refresh-outline" + }, + "ups_timer_shutdown": { + "default": "mdi:timer-stop-outline" + }, + "ups_timer_start": { + "default": "mdi:timer-play-outline" + }, "ups_type": { "default": "mdi:information-outline" }, "ups_watchdog_status": { "default": "mdi:information-outline" } + }, + "switch": { + "outlet_number_load_poweronoff": { + "default": "mdi:power" + } } } } diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 1484f11dac7..5822f7f7b02 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -1,10 +1,9 @@ -"""Provides a sensor to track various status aspects of a UPS.""" +"""Provides a sensor to track various status aspects of a NUT device.""" from __future__ import annotations -from dataclasses import asdict import logging -from typing import Final, cast +from typing import Final from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,10 +12,6 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SERIAL_NUMBER, - ATTR_SW_VERSION, PERCENTAGE, STATE_UNKNOWN, EntityCategory, @@ -29,22 +24,14 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) -from . import NutConfigEntry, PyNUTData -from .const import DOMAIN, KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES +from . import NutConfigEntry +from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES +from .entity import NUTBaseEntity -NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { - "manufacturer": ATTR_MANUFACTURER, - "model": ATTR_MODEL, - "firmware": ATTR_SW_VERSION, - "serial": ATTR_SERIAL_NUMBER, -} +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 AMBIENT_PRESENT = "ambient.present" AMBIENT_SENSORS = { @@ -53,63 +40,790 @@ AMBIENT_SENSORS = { "ambient.temperature", "ambient.temperature.status", } -AMBIENT_THRESHOLD_STATUS_OPTIONS = [ +BATTERY_CHARGER_STATUS_OPTIONS = [ + "charging", + "discharging", + "floating", + "resting", + "unknown", + "disabled", + "off", +] +FREQUENCY_STATUS_OPTIONS = [ + "good", + "out-of-range", +] +THRESHOLD_STATUS_OPTIONS = [ "good", "warning-low", "critical-low", "warning-high", "critical-high", ] +UPS_BEEPER_STATUS_OPTIONS = [ + "enabled", + "disabled", + "muted", +] _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { - "ups.status.display": SensorEntityDescription( - key="ups.status.display", - translation_key="ups_status_display", + "ambient.humidity": SensorEntityDescription( + key="ambient.humidity", + translation_key="ambient_humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), - "ups.status": SensorEntityDescription( - key="ups.status", - translation_key="ups_status", + "ambient.humidity.status": SensorEntityDescription( + key="ambient.humidity.status", + translation_key="ambient_humidity_status", + device_class=SensorDeviceClass.ENUM, + options=THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, ), - "ups.alarm": SensorEntityDescription( - key="ups.alarm", - translation_key="ups_alarm", + "ambient.temperature": SensorEntityDescription( + key="ambient.temperature", + translation_key="ambient_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), - "ups.temperature": SensorEntityDescription( - key="ups.temperature", - translation_key="ups_temperature", + "ambient.temperature.status": SensorEntityDescription( + key="ambient.temperature.status", + translation_key="ambient_temperature_status", + device_class=SensorDeviceClass.ENUM, + options=THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "battery.alarm.threshold": SensorEntityDescription( + key="battery.alarm.threshold", + translation_key="battery_alarm_threshold", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.capacity": SensorEntityDescription( + key="battery.capacity", + translation_key="battery_capacity", + native_unit_of_measurement="Ah", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charge": SensorEntityDescription( + key="battery.charge", + translation_key="battery_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + "battery.charge.low": SensorEntityDescription( + key="battery.charge.low", + translation_key="battery_charge_low", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charge.restart": SensorEntityDescription( + key="battery.charge.restart", + translation_key="battery_charge_restart", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charge.warning": SensorEntityDescription( + key="battery.charge.warning", + translation_key="battery_charge_warning", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charger.status": SensorEntityDescription( + key="battery.charger.status", + translation_key="battery_charger_status", + device_class=SensorDeviceClass.ENUM, + options=BATTERY_CHARGER_STATUS_OPTIONS, + ), + "battery.current": SensorEntityDescription( + key="battery.current", + translation_key="battery_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.current.total": SensorEntityDescription( + key="battery.current.total", + translation_key="battery_current_total", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.date": SensorEntityDescription( + key="battery.date", + translation_key="battery_date", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.mfr.date": SensorEntityDescription( + key="battery.mfr.date", + translation_key="battery_mfr_date", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.packs": SensorEntityDescription( + key="battery.packs", + translation_key="battery_packs", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.packs.bad": SensorEntityDescription( + key="battery.packs.bad", + translation_key="battery_packs_bad", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.runtime": SensorEntityDescription( + key="battery.runtime", + translation_key="battery_runtime", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.runtime.low": SensorEntityDescription( + key="battery.runtime.low", + translation_key="battery_runtime_low", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.runtime.restart": SensorEntityDescription( + key="battery.runtime.restart", + translation_key="battery_runtime_restart", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.temperature": SensorEntityDescription( + key="battery.temperature", + translation_key="battery_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.load": SensorEntityDescription( - key="ups.load", - translation_key="ups_load", + "battery.type": SensorEntityDescription( + key="battery.type", + translation_key="battery_type", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage": SensorEntityDescription( + key="battery.voltage", + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage.high": SensorEntityDescription( + key="battery.voltage.high", + translation_key="battery_voltage_high", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage.low": SensorEntityDescription( + key="battery.voltage.low", + translation_key="battery_voltage_low", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage.nominal": SensorEntityDescription( + key="battery.voltage.nominal", + translation_key="battery_voltage_nominal", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.current": SensorEntityDescription( + key="input.bypass.current", + translation_key="input_bypass_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.frequency": SensorEntityDescription( + key="input.bypass.frequency", + translation_key="input_bypass_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.current": SensorEntityDescription( + key="input.bypass.L1.current", + translation_key="input_bypass_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1-N.voltage": SensorEntityDescription( + key="input.bypass.L1-N.voltage", + translation_key="input_bypass_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.realpower": SensorEntityDescription( + key="input.bypass.L1.realpower", + translation_key="input_bypass_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.current": SensorEntityDescription( + key="input.bypass.L2.current", + translation_key="input_bypass_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2-N.voltage": SensorEntityDescription( + key="input.bypass.L2-N.voltage", + translation_key="input_bypass_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.realpower": SensorEntityDescription( + key="input.bypass.L2.realpower", + translation_key="input_bypass_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.current": SensorEntityDescription( + key="input.bypass.L3.current", + translation_key="input_bypass_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3-N.voltage": SensorEntityDescription( + key="input.bypass.L3-N.voltage", + translation_key="input_bypass_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.realpower": SensorEntityDescription( + key="input.bypass.L3.realpower", + translation_key="input_bypass_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.phases": SensorEntityDescription( + key="input.bypass.phases", + translation_key="input_bypass_phases", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.realpower": SensorEntityDescription( + key="input.bypass.realpower", + translation_key="input_bypass_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.voltage": SensorEntityDescription( + key="input.bypass.voltage", + translation_key="input_bypass_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.current": SensorEntityDescription( + key="input.current", + translation_key="input_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "input.current.status": SensorEntityDescription( + key="input.current.status", + translation_key="input_current_status", + device_class=SensorDeviceClass.ENUM, + options=THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.frequency": SensorEntityDescription( + key="input.frequency", + translation_key="input_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.frequency.nominal": SensorEntityDescription( + key="input.frequency.nominal", + translation_key="input_frequency_nominal", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.frequency.status": SensorEntityDescription( + key="input.frequency.status", + translation_key="input_frequency_status", + device_class=SensorDeviceClass.ENUM, + options=FREQUENCY_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1.current": SensorEntityDescription( + key="input.L1.current", + translation_key="input_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1.frequency": SensorEntityDescription( + key="input.L1.frequency", + translation_key="input_l1_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1-N.voltage": SensorEntityDescription( + key="input.L1-N.voltage", + translation_key="input_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1.realpower": SensorEntityDescription( + key="input.L1.realpower", + translation_key="input_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.current": SensorEntityDescription( + key="input.L2.current", + translation_key="input_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.frequency": SensorEntityDescription( + key="input.L2.frequency", + translation_key="input_l2_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2-N.voltage": SensorEntityDescription( + key="input.L2-N.voltage", + translation_key="input_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.realpower": SensorEntityDescription( + key="input.L2.realpower", + translation_key="input_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.current": SensorEntityDescription( + key="input.L3.current", + translation_key="input_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.frequency": SensorEntityDescription( + key="input.L3.frequency", + translation_key="input_l3_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3-N.voltage": SensorEntityDescription( + key="input.L3-N.voltage", + translation_key="input_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.realpower": SensorEntityDescription( + key="input.L3.realpower", + translation_key="input_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.load": SensorEntityDescription( + key="input.load", + translation_key="input_load", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - "ups.load.high": SensorEntityDescription( - key="ups.load.high", - translation_key="ups_load_high", + "input.phases": SensorEntityDescription( + key="input.phases", + translation_key="input_phases", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.power": SensorEntityDescription( + key="input.power", + translation_key="input_power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.realpower": SensorEntityDescription( + key="input.realpower", + translation_key="input_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.sensitivity": SensorEntityDescription( + key="input.sensitivity", + translation_key="input_sensitivity", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.transfer.high": SensorEntityDescription( + key="input.transfer.high", + translation_key="input_transfer_high", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.transfer.low": SensorEntityDescription( + key="input.transfer.low", + translation_key="input_transfer_low", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.transfer.reason": SensorEntityDescription( + key="input.transfer.reason", + translation_key="input_transfer_reason", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.voltage": SensorEntityDescription( + key="input.voltage", + translation_key="input_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "input.voltage.nominal": SensorEntityDescription( + key="input.voltage.nominal", + translation_key="input_voltage_nominal", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.voltage.status": SensorEntityDescription( + key="input.voltage.status", + translation_key="input_voltage_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "outlet.voltage": SensorEntityDescription( + key="outlet.voltage", + translation_key="outlet_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "output.current": SensorEntityDescription( + key="output.current", + translation_key="output_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.current.nominal": SensorEntityDescription( + key="output.current.nominal", + translation_key="output_current_nominal", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.frequency": SensorEntityDescription( + key="output.frequency", + translation_key="output_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.frequency.nominal": SensorEntityDescription( + key="output.frequency.nominal", + translation_key="output_frequency_nominal", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L1.current": SensorEntityDescription( + key="output.L1.current", + translation_key="output_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L1-N.voltage": SensorEntityDescription( + key="output.L1-N.voltage", + translation_key="output_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L1.power.percent": SensorEntityDescription( + key="output.L1.power.percent", + translation_key="output_l1_power_percent", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.id": SensorEntityDescription( - key="ups.id", - translation_key="ups_id", + "output.L1.realpower": SensorEntityDescription( + key="output.L1.realpower", + translation_key="output_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.delay.start": SensorEntityDescription( - key="ups.delay.start", - translation_key="ups_delay_start", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, + "output.L2.current": SensorEntityDescription( + key="output.L2.current", + translation_key="output_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2-N.voltage": SensorEntityDescription( + key="output.L2-N.voltage", + translation_key="output_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.power.percent": SensorEntityDescription( + key="output.L2.power.percent", + translation_key="output_l2_power_percent", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.realpower": SensorEntityDescription( + key="output.L2.realpower", + translation_key="output_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.current": SensorEntityDescription( + key="output.L3.current", + translation_key="output_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3-N.voltage": SensorEntityDescription( + key="output.L3-N.voltage", + translation_key="output_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.power.percent": SensorEntityDescription( + key="output.L3.power.percent", + translation_key="output_l3_power_percent", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.realpower": SensorEntityDescription( + key="output.L3.realpower", + translation_key="output_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.phases": SensorEntityDescription( + key="output.phases", + translation_key="output_phases", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.power": SensorEntityDescription( + key="output.power", + translation_key="output_power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.power.nominal": SensorEntityDescription( + key="output.power.nominal", + translation_key="output_power_nominal", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.realpower": SensorEntityDescription( + key="output.realpower", + translation_key="output_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.realpower.nominal": SensorEntityDescription( + key="output.realpower.nominal", + translation_key="output_realpower_nominal", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.voltage": SensorEntityDescription( + key="output.voltage", + translation_key="output_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "output.voltage.nominal": SensorEntityDescription( + key="output.voltage.nominal", + translation_key="output_voltage_nominal", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.alarm": SensorEntityDescription( + key="ups.alarm", + translation_key="ups_alarm", + ), + "ups.beeper.status": SensorEntityDescription( + key="ups.beeper.status", + translation_key="ups_beeper_status", + device_class=SensorDeviceClass.ENUM, + options=UPS_BEEPER_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.contacts": SensorEntityDescription( + key="ups.contacts", + translation_key="ups_contacts", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -129,62 +843,20 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.timer.start": SensorEntityDescription( - key="ups.timer.start", - translation_key="ups_timer_start", + "ups.delay.start": SensorEntityDescription( + key="ups.delay.start", + translation_key="ups_delay_start", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.timer.reboot": SensorEntityDescription( - key="ups.timer.reboot", - translation_key="ups_timer_reboot", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.timer.shutdown": SensorEntityDescription( - key="ups.timer.shutdown", - translation_key="ups_timer_shutdown", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.test.interval": SensorEntityDescription( - key="ups.test.interval", - translation_key="ups_test_interval", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.test.result": SensorEntityDescription( - key="ups.test.result", - translation_key="ups_test_result", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.test.date": SensorEntityDescription( - key="ups.test.date", - translation_key="ups_test_date", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), "ups.display.language": SensorEntityDescription( key="ups.display.language", translation_key="ups_display_language", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.contacts": SensorEntityDescription( - key="ups.contacts", - translation_key="ups_contacts", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), "ups.efficiency": SensorEntityDescription( key="ups.efficiency", translation_key="ups_efficiency", @@ -193,6 +865,25 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "ups.id": SensorEntityDescription( + key="ups.id", + translation_key="ups_id", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.load": SensorEntityDescription( + key="ups.load", + translation_key="ups_load", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "ups.load.high": SensorEntityDescription( + key="ups.load.high", + translation_key="ups_load_high", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "ups.power": SensorEntityDescription( key="ups.power", translation_key="ups_power", @@ -227,21 +918,9 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.beeper.status": SensorEntityDescription( - key="ups.beeper.status", - translation_key="ups_beeper_status", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.type": SensorEntityDescription( - key="ups.type", - translation_key="ups_type", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.watchdog.status": SensorEntityDescription( - key="ups.watchdog.status", - translation_key="ups_watchdog_status", + "ups.shutdown": SensorEntityDescription( + key="ups.shutdown", + translation_key="ups_shutdown", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -263,772 +942,89 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.shutdown": SensorEntityDescription( - key="ups.shutdown", - translation_key="ups_shutdown", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, + "ups.status": SensorEntityDescription( + key="ups.status", + translation_key="ups_status", ), - "battery.charge": SensorEntityDescription( - key="battery.charge", - translation_key="battery_charge", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.BATTERY, - state_class=SensorStateClass.MEASUREMENT, + "ups.status.display": SensorEntityDescription( + key="ups.status.display", + translation_key="ups_status_display", ), - "battery.charge.low": SensorEntityDescription( - key="battery.charge.low", - translation_key="battery_charge_low", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.charge.restart": SensorEntityDescription( - key="battery.charge.restart", - translation_key="battery_charge_restart", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.charge.warning": SensorEntityDescription( - key="battery.charge.warning", - translation_key="battery_charge_warning", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.charger.status": SensorEntityDescription( - key="battery.charger.status", - translation_key="battery_charger_status", - ), - "battery.voltage": SensorEntityDescription( - key="battery.voltage", - translation_key="battery_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.voltage.nominal": SensorEntityDescription( - key="battery.voltage.nominal", - translation_key="battery_voltage_nominal", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.voltage.low": SensorEntityDescription( - key="battery.voltage.low", - translation_key="battery_voltage_low", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.voltage.high": SensorEntityDescription( - key="battery.voltage.high", - translation_key="battery_voltage_high", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.capacity": SensorEntityDescription( - key="battery.capacity", - translation_key="battery_capacity", - native_unit_of_measurement="Ah", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.current": SensorEntityDescription( - key="battery.current", - translation_key="battery_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.current.total": SensorEntityDescription( - key="battery.current.total", - translation_key="battery_current_total", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.temperature": SensorEntityDescription( - key="battery.temperature", - translation_key="battery_temperature", + "ups.temperature": SensorEntityDescription( + key="ups.temperature", + translation_key="ups_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.runtime": SensorEntityDescription( - key="battery.runtime", - translation_key="battery_runtime", + "ups.test.date": SensorEntityDescription( + key="ups.test.date", + translation_key="ups_test_date", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.test.interval": SensorEntityDescription( + key="ups.test.interval", + translation_key="ups_test_interval", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.runtime.low": SensorEntityDescription( - key="battery.runtime.low", - translation_key="battery_runtime_low", + "ups.test.result": SensorEntityDescription( + key="ups.test.result", + translation_key="ups_test_result", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.timer.reboot": SensorEntityDescription( + key="ups.timer.reboot", + translation_key="ups_timer_reboot", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.runtime.restart": SensorEntityDescription( - key="battery.runtime.restart", - translation_key="battery_runtime_restart", + "ups.timer.shutdown": SensorEntityDescription( + key="ups.timer.shutdown", + translation_key="ups_timer_shutdown", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.alarm.threshold": SensorEntityDescription( - key="battery.alarm.threshold", - translation_key="battery_alarm_threshold", + "ups.timer.start": SensorEntityDescription( + key="ups.timer.start", + translation_key="ups_timer_start", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.date": SensorEntityDescription( - key="battery.date", - translation_key="battery_date", + "ups.type": SensorEntityDescription( + key="ups.type", + translation_key="ups_type", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.mfr.date": SensorEntityDescription( - key="battery.mfr.date", - translation_key="battery_mfr_date", + "ups.watchdog.status": SensorEntityDescription( + key="ups.watchdog.status", + translation_key="ups_watchdog_status", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.packs": SensorEntityDescription( - key="battery.packs", - translation_key="battery_packs", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.packs.bad": SensorEntityDescription( - key="battery.packs.bad", - translation_key="battery_packs_bad", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.type": SensorEntityDescription( - key="battery.type", - translation_key="battery_type", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.sensitivity": SensorEntityDescription( - key="input.sensitivity", - translation_key="input_sensitivity", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.transfer.low": SensorEntityDescription( - key="input.transfer.low", - translation_key="input_transfer_low", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.transfer.high": SensorEntityDescription( - key="input.transfer.high", - translation_key="input_transfer_high", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.transfer.reason": SensorEntityDescription( - key="input.transfer.reason", - translation_key="input_transfer_reason", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.voltage": SensorEntityDescription( - key="input.voltage", - translation_key="input_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "input.voltage.nominal": SensorEntityDescription( - key="input.voltage.nominal", - translation_key="input_voltage_nominal", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.voltage.status": SensorEntityDescription( - key="input.voltage.status", - translation_key="input_voltage_status", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L1-N.voltage": SensorEntityDescription( - key="input.L1-N.voltage", - translation_key="input_l1_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2-N.voltage": SensorEntityDescription( - key="input.L2-N.voltage", - translation_key="input_l2_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3-N.voltage": SensorEntityDescription( - key="input.L3-N.voltage", - translation_key="input_l3_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.frequency": SensorEntityDescription( - key="input.frequency", - translation_key="input_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.frequency.nominal": SensorEntityDescription( - key="input.frequency.nominal", - translation_key="input_frequency_nominal", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.frequency.status": SensorEntityDescription( - key="input.frequency.status", - translation_key="input_frequency_status", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L1.frequency": SensorEntityDescription( - key="input.L1.frequency", - translation_key="input_l1_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2.frequency": SensorEntityDescription( - key="input.L2.frequency", - translation_key="input_l2_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3.frequency": SensorEntityDescription( - key="input.L3.frequency", - translation_key="input_l3_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.current": SensorEntityDescription( - key="input.bypass.current", - translation_key="input_bypass_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L1.current": SensorEntityDescription( - key="input.bypass.L1.current", - translation_key="input_bypass_l1_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L2.current": SensorEntityDescription( - key="input.bypass.L2.current", - translation_key="input_bypass_l2_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L3.current": SensorEntityDescription( - key="input.bypass.L3.current", - translation_key="input_bypass_l3_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.frequency": SensorEntityDescription( - key="input.bypass.frequency", - translation_key="input_bypass_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.phases": SensorEntityDescription( - key="input.bypass.phases", - translation_key="input_bypass_phases", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.realpower": SensorEntityDescription( - key="input.bypass.realpower", - translation_key="input_bypass_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L1.realpower": SensorEntityDescription( - key="input.bypass.L1.realpower", - translation_key="input_bypass_l1_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L2.realpower": SensorEntityDescription( - key="input.bypass.L2.realpower", - translation_key="input_bypass_l2_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L3.realpower": SensorEntityDescription( - key="input.bypass.L3.realpower", - translation_key="input_bypass_l3_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.voltage": SensorEntityDescription( - key="input.bypass.voltage", - translation_key="input_bypass_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L1-N.voltage": SensorEntityDescription( - key="input.bypass.L1-N.voltage", - translation_key="input_bypass_l1_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L2-N.voltage": SensorEntityDescription( - key="input.bypass.L2-N.voltage", - translation_key="input_bypass_l2_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L3-N.voltage": SensorEntityDescription( - key="input.bypass.L3-N.voltage", - translation_key="input_bypass_l3_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.current": SensorEntityDescription( - key="input.current", - translation_key="input_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - "input.current.status": SensorEntityDescription( - key="input.current.status", - translation_key="input_current_status", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L1.current": SensorEntityDescription( - key="input.L1.current", - translation_key="input_l1_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2.current": SensorEntityDescription( - key="input.L2.current", - translation_key="input_l2_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3.current": SensorEntityDescription( - key="input.L3.current", - translation_key="input_l3_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.load": SensorEntityDescription( - key="input.load", - translation_key="input_load", - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "input.phases": SensorEntityDescription( - key="input.phases", - translation_key="input_phases", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.power": SensorEntityDescription( - key="input.power", - translation_key="input_power", - device_class=SensorDeviceClass.APPARENT_POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.realpower": SensorEntityDescription( - key="input.realpower", - translation_key="input_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L1.realpower": SensorEntityDescription( - key="input.L1.realpower", - translation_key="input_l1_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2.realpower": SensorEntityDescription( - key="input.L2.realpower", - translation_key="input_l2_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3.realpower": SensorEntityDescription( - key="input.L3.realpower", - translation_key="input_l3_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "outlet.voltage": SensorEntityDescription( - key="outlet.voltage", - translation_key="outlet_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "output.power.nominal": SensorEntityDescription( - key="output.power.nominal", - translation_key="output_power_nominal", - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - device_class=SensorDeviceClass.APPARENT_POWER, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1.power.percent": SensorEntityDescription( - key="output.L1.power.percent", - translation_key="output_l1_power_percent", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2.power.percent": SensorEntityDescription( - key="output.L2.power.percent", - translation_key="output_l2_power_percent", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3.power.percent": SensorEntityDescription( - key="output.L3.power.percent", - translation_key="output_l3_power_percent", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.current": SensorEntityDescription( - key="output.current", - translation_key="output_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.current.nominal": SensorEntityDescription( - key="output.current.nominal", - translation_key="output_current_nominal", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1.current": SensorEntityDescription( - key="output.L1.current", - translation_key="output_l1_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2.current": SensorEntityDescription( - key="output.L2.current", - translation_key="output_l2_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3.current": SensorEntityDescription( - key="output.L3.current", - translation_key="output_l3_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.voltage": SensorEntityDescription( - key="output.voltage", - translation_key="output_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "output.voltage.nominal": SensorEntityDescription( - key="output.voltage.nominal", - translation_key="output_voltage_nominal", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1-N.voltage": SensorEntityDescription( - key="output.L1-N.voltage", - translation_key="output_l1_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2-N.voltage": SensorEntityDescription( - key="output.L2-N.voltage", - translation_key="output_l2_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3-N.voltage": SensorEntityDescription( - key="output.L3-N.voltage", - translation_key="output_l3_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.frequency": SensorEntityDescription( - key="output.frequency", - translation_key="output_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.frequency.nominal": SensorEntityDescription( - key="output.frequency.nominal", - translation_key="output_frequency_nominal", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.phases": SensorEntityDescription( - key="output.phases", - translation_key="output_phases", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.power": SensorEntityDescription( - key="output.power", - translation_key="output_power", - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - device_class=SensorDeviceClass.APPARENT_POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.realpower": SensorEntityDescription( - key="output.realpower", - translation_key="output_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.realpower.nominal": SensorEntityDescription( - key="output.realpower.nominal", - translation_key="output_realpower_nominal", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1.realpower": SensorEntityDescription( - key="output.L1.realpower", - translation_key="output_l1_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2.realpower": SensorEntityDescription( - key="output.L2.realpower", - translation_key="output_l2_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3.realpower": SensorEntityDescription( - key="output.L3.realpower", - translation_key="output_l3_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ambient.humidity": SensorEntityDescription( - key="ambient.humidity", - translation_key="ambient_humidity", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "ambient.humidity.status": SensorEntityDescription( - key="ambient.humidity.status", - translation_key="ambient_humidity_status", - device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "ambient.temperature": SensorEntityDescription( - key="ambient.temperature", - translation_key="ambient_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "ambient.temperature.status": SensorEntityDescription( - key="ambient.temperature.status", - translation_key="ambient_temperature_status", - device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "watts": SensorEntityDescription( - key="watts", - translation_key="watts", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), } -def _get_nut_device_info(data: PyNUTData) -> DeviceInfo: - """Return a DeviceInfo object filled with NUT device info.""" - nut_dev_infos = asdict(data.device_info) - nut_infos = { - info_key: nut_dev_infos[nut_key] - for nut_key, info_key in NUT_DEV_INFO_TO_DEV_INFO.items() - if nut_dev_infos[nut_key] is not None - } - - return cast(DeviceInfo, nut_infos) - - async def async_setup_entry( hass: HomeAssistant, config_entry: NutConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NUT sensors.""" + valid_sensor_types: dict[str, SensorEntityDescription] pynut_data = config_entry.runtime_data coordinator = pynut_data.coordinator @@ -1036,20 +1032,75 @@ async def async_setup_entry( unique_id = pynut_data.unique_id status = coordinator.data - resources = [sensor_id for sensor_id in SENSOR_TYPES if sensor_id in status] - # Display status is a special case that falls back to the status value - # of the UPS instead. - if KEY_STATUS in resources: - resources.append(KEY_STATUS_DISPLAY) + # Dynamically add outlet sensors to valid sensors dictionary + if (num_outlets := status.get("outlet.count")) is not None: + additional_sensor_types: dict[str, SensorEntityDescription] = {} + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str: str = str(outlet_num) + outlet_name: str = ( + status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str + ) + additional_sensor_types |= { + f"outlet.{outlet_num_str}.current": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.current", + translation_key="outlet_number_current", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + f"outlet.{outlet_num_str}.current_status": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.current_status", + translation_key="outlet_number_current_status", + translation_placeholders={"outlet_name": outlet_name}, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + f"outlet.{outlet_num_str}.desc": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.desc", + translation_key="outlet_number_desc", + translation_placeholders={"outlet_name": outlet_name}, + ), + f"outlet.{outlet_num_str}.power": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.power", + translation_key="outlet_number_power", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + f"outlet.{outlet_num_str}.realpower": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.realpower", + translation_key="outlet_number_realpower", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + } + + valid_sensor_types = {**SENSOR_TYPES, **additional_sensor_types} + else: + valid_sensor_types = SENSOR_TYPES # If device reports ambient sensors are not present, then remove - if status.get(AMBIENT_PRESENT) == "no": - resources = [item for item in resources if item not in AMBIENT_SENSORS] + has_ambient_sensors: bool = status.get(AMBIENT_PRESENT) != "no" + resources = [ + sensor_id + for sensor_id in valid_sensor_types + if sensor_id in status + and (has_ambient_sensors or sensor_id not in AMBIENT_SENSORS) + ] + + # Display status is a special case that falls back to the status value + # of the UPS instead. + if KEY_STATUS in status: + resources.append(KEY_STATUS_DISPLAY) async_add_entities( NUTSensor( coordinator, - SENSOR_TYPES[sensor_type], + valid_sensor_types[sensor_type], data, unique_id, ) @@ -1057,33 +1108,12 @@ async def async_setup_entry( ) -class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], SensorEntity): +class NUTSensor(NUTBaseEntity, SensorEntity): """Representation of a sensor entity for NUT status values.""" - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, str]], - sensor_description: SensorEntityDescription, - data: PyNUTData, - unique_id: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.entity_description = sensor_description - - device_name = data.name.title() - self._attr_unique_id = f"{unique_id}_{sensor_description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - name=device_name, - ) - self._attr_device_info.update(_get_nut_device_info(data)) - @property def native_value(self) -> str | None: - """Return entity state from ups.""" + """Return entity state from NUT device.""" status = self.coordinator.data if self.entity_description.key == KEY_STATUS_DISPLAY: return _format_display_state(status) @@ -1093,6 +1123,6 @@ class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], Sensor def _format_display_state(status: dict[str, str]) -> str: """Return UPS display state.""" try: - return " ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split()) + return ", ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split()) except KeyError: return STATE_UNKNOWN diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 08971732bc6..1e6cee786d3 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -10,13 +10,16 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your NUT server." + "host": "The IP address or hostname of your NUT server.", + "port": "The network port of your NUT server. The NUT server's default port is '3493'.", + "username": "The username to sign in to your NUT server. The username is optional.", + "password": "The password to sign in to your NUT server. The password is optional." } }, "ups": { - "title": "Choose the UPS to Monitor", + "title": "Choose the NUT server UPS to monitor", "data": { - "alias": "Alias" + "alias": "NUT server UPS name" } }, "reauth_confirm": { @@ -25,26 +28,40 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reconfigure": { + "description": "[%key:component::nut::config::step::user::description%]", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "[%key:component::nut::config::step::user::data_description::host%]", + "port": "[%key:component::nut::config::step::user::data_description::port%]", + "username": "[%key:component::nut::config::step::user::data_description::username%]", + "password": "[%key:component::nut::config::step::user::data_description::password%]" + } + }, + "reconfigure_ups": { + "title": "[%key:component::nut::config::step::ups::title%]", + "data": { + "alias": "[%key:component::nut::config::step::ups::data::alias%]" + } } }, "error": { "cannot_connect": "Connection error: {error}", - "unknown": "[%key:common::config_flow::error::unknown%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_ups_found": "There are no UPS devices available on the NUT server.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Scan Interval (seconds)" - } - } + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device's manufacturer, model and serial number identifier does not match the previous identifier." } }, "device_automation": { @@ -74,26 +91,54 @@ "test_failure_stop": "Stop simulating a power failure", "test_panel_start": "Start testing the UPS panel", "test_panel_stop": "Stop a UPS panel test", - "test_system_start": "Start a system test", - "outlet_1_load_on": "Power outlet 1 on", - "outlet_1_load_off": "Power outlet 1 off", - "outlet_2_load_on": "Power outlet 2 on", - "outlet_2_load_off": "Power outlet 2 off" + "test_system_start": "Start a system test" } }, "entity": { + "button": { + "outlet_number_load_cycle": { "name": "Power cycle outlet {outlet_name}" } + }, "sensor": { "ambient_humidity": { "name": "Ambient humidity" }, - "ambient_humidity_status": { "name": "Ambient humidity status" }, + "ambient_humidity_status": { + "name": "Ambient humidity status", + "state": { + "good": "Good", + "warning-low": "Warning low", + "critical-low": "Critical low", + "warning-high": "Warning high", + "critical-high": "Critical high" + } + }, "ambient_temperature": { "name": "Ambient temperature" }, - "ambient_temperature_status": { "name": "Ambient temperature status" }, + "ambient_temperature_status": { + "name": "Ambient temperature status", + "state": { + "good": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::good%]", + "warning-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-low%]", + "critical-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-low%]", + "warning-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-high%]", + "critical-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-high%]" + } + }, "battery_alarm_threshold": { "name": "Battery alarm threshold" }, "battery_capacity": { "name": "Battery capacity" }, "battery_charge": { "name": "Battery charge" }, "battery_charge_low": { "name": "Low battery setpoint" }, "battery_charge_restart": { "name": "Minimum battery to start" }, "battery_charge_warning": { "name": "Warning battery setpoint" }, - "battery_charger_status": { "name": "Charging status" }, + "battery_charger_status": { + "name": "Charging status", + "state": { + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "floating": "Floating", + "resting": "Resting", + "unknown": "Unknown", + "disabled": "[%key:common::state::disabled%]", + "off": "Off" + } + }, "battery_current": { "name": "Battery current" }, "battery_current_total": { "name": "Total battery current" }, "battery_date": { "name": "Battery date" }, @@ -110,43 +155,55 @@ "battery_voltage_low": { "name": "Low battery voltage" }, "battery_voltage_nominal": { "name": "Nominal battery voltage" }, "input_bypass_current": { "name": "Input bypass current" }, - "input_bypass_l1_current": { "name": "Input bypass L1 current" }, - "input_bypass_l2_current": { "name": "Input bypass L2 current" }, - "input_bypass_l3_current": { "name": "Input bypass L3 current" }, - "input_bypass_voltage": { "name": "Input bypass voltage" }, - "input_bypass_l1_n_voltage": { "name": "Input bypass L1-N voltage" }, - "input_bypass_l2_n_voltage": { "name": "Input bypass L2-N voltage" }, - "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, "input_bypass_frequency": { "name": "Input bypass frequency" }, + "input_bypass_l1_current": { "name": "Input bypass L1 current" }, + "input_bypass_l1_n_voltage": { "name": "Input bypass L1-N voltage" }, + "input_bypass_l1_realpower": { "name": "Input bypass L1 real power" }, + "input_bypass_l2_current": { "name": "Input bypass L2 current" }, + "input_bypass_l2_n_voltage": { "name": "Input bypass L2-N voltage" }, + "input_bypass_l2_realpower": { "name": "Input bypass L2 real power" }, + "input_bypass_l3_current": { "name": "Input bypass L3 current" }, + "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, + "input_bypass_l3_realpower": { "name": "Input bypass L3 real power" }, "input_bypass_phases": { "name": "Input bypass phases" }, "input_bypass_realpower": { "name": "Input bypass real power" }, - "input_bypass_l1_realpower": { - "name": "Input bypass L1 real power" - }, - "input_bypass_l2_realpower": { - "name": "Input bypass L2 real power" - }, - "input_bypass_l3_realpower": { - "name": "Input bypass L3 real power" - }, + "input_bypass_voltage": { "name": "Input bypass voltage" }, "input_current": { "name": "Input current" }, - "input_current_status": { "name": "Input current status" }, - "input_l1_current": { "name": "Input L1 current" }, - "input_l2_current": { "name": "Input L2 current" }, - "input_l3_current": { "name": "Input L3 current" }, + "input_current_status": { + "name": "Input current status", + "state": { + "good": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::good%]", + "warning-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-low%]", + "critical-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-low%]", + "warning-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-high%]", + "critical-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-high%]" + } + }, "input_frequency": { "name": "Input frequency" }, "input_frequency_nominal": { "name": "Input nominal frequency" }, - "input_frequency_status": { "name": "Input frequency status" }, + "input_frequency_status": { + "name": "Input frequency status", + "state": { + "good": "Good", + "out-of-range": "Out of range" + } + }, + "input_l1_current": { "name": "Input L1 current" }, "input_l1_frequency": { "name": "Input L1 line frequency" }, + "input_l1_n_voltage": { "name": "Input L1 voltage" }, + "input_l1_realpower": { "name": "Input L1 real power" }, + "input_l2_current": { "name": "Input L2 current" }, "input_l2_frequency": { "name": "Input L2 line frequency" }, + "input_l2_n_voltage": { "name": "Input L2 voltage" }, + "input_l2_realpower": { "name": "Input L2 real power" }, + "input_l3_current": { "name": "Input L3 current" }, "input_l3_frequency": { "name": "Input L3 line frequency" }, + "input_l3_n_voltage": { "name": "Input L3 voltage" }, + "input_l3_realpower": { "name": "Input L3 real power" }, + "input_load": { "name": "Input load" }, "input_phases": { "name": "Input phases" }, "input_power": { "name": "Input power" }, "input_realpower": { "name": "Input real power" }, - "input_l1_realpower": { "name": "Input L1 real power" }, - "input_l2_realpower": { "name": "Input L2 real power" }, - "input_l3_realpower": { "name": "Input L3 real power" }, - "input_load": { "name": "Input load" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, @@ -154,35 +211,46 @@ "input_voltage": { "name": "Input voltage" }, "input_voltage_nominal": { "name": "Nominal input voltage" }, "input_voltage_status": { "name": "Input voltage status" }, - "input_l1_n_voltage": { "name": "Input L1 voltage" }, - "input_l2_n_voltage": { "name": "Input L2 voltage" }, - "input_l3_n_voltage": { "name": "Input L3 voltage" }, + "outlet_number_current": { "name": "Outlet {outlet_name} current" }, + "outlet_number_current_status": { + "name": "Outlet {outlet_name} current status" + }, + "outlet_number_desc": { "name": "Outlet {outlet_name} description" }, + "outlet_number_power": { "name": "Outlet {outlet_name} power" }, + "outlet_number_realpower": { "name": "Outlet {outlet_name} real power" }, "outlet_voltage": { "name": "Outlet voltage" }, "output_current": { "name": "Output current" }, "output_current_nominal": { "name": "Nominal output current" }, - "output_l1_current": { "name": "Output L1 current" }, - "output_l2_current": { "name": "Output L2 current" }, - "output_l3_current": { "name": "Output L3 current" }, "output_frequency": { "name": "Output frequency" }, "output_frequency_nominal": { "name": "Nominal output frequency" }, + "output_l1_current": { "name": "Output L1 current" }, + "output_l1_n_voltage": { "name": "Output L1-N voltage" }, + "output_l1_power_percent": { "name": "Output L1 power usage" }, + "output_l1_realpower": { "name": "Output L1 real power" }, + "output_l2_current": { "name": "Output L2 current" }, + "output_l2_n_voltage": { "name": "Output L2-N voltage" }, + "output_l2_power_percent": { "name": "Output L2 power usage" }, + "output_l2_realpower": { "name": "Output L2 real power" }, + "output_l3_current": { "name": "Output L3 current" }, + "output_l3_n_voltage": { "name": "Output L3-N voltage" }, + "output_l3_power_percent": { "name": "Output L3 power usage" }, + "output_l3_realpower": { "name": "Output L3 real power" }, "output_phases": { "name": "Output phases" }, "output_power": { "name": "Output apparent power" }, - "output_l2_power_percent": { "name": "Output L2 power usage" }, - "output_l1_power_percent": { "name": "Output L1 power usage" }, - "output_l3_power_percent": { "name": "Output L3 power usage" }, "output_power_nominal": { "name": "Nominal output power" }, "output_realpower": { "name": "Output real power" }, "output_realpower_nominal": { "name": "Nominal output real power" }, - "output_l1_realpower": { "name": "Output L1 real power" }, - "output_l2_realpower": { "name": "Output L2 real power" }, - "output_l3_realpower": { "name": "Output L3 real power" }, "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, - "output_l1_n_voltage": { "name": "Output L1-N voltage" }, - "output_l2_n_voltage": { "name": "Output L2-N voltage" }, - "output_l3_n_voltage": { "name": "Output L3-N voltage" }, "ups_alarm": { "name": "Alarms" }, - "ups_beeper_status": { "name": "Beeper status" }, + "ups_beeper_status": { + "name": "Beeper status", + "state": { + "enabled": "[%key:common::state::enabled%]", + "disabled": "[%key:common::state::disabled%]", + "muted": "Muted" + } + }, "ups_contacts": { "name": "External contacts" }, "ups_delay_reboot": { "name": "UPS reboot delay" }, "ups_delay_shutdown": { "name": "UPS shutdown delay" }, @@ -212,8 +280,24 @@ "ups_timer_shutdown": { "name": "Load shutdown timer" }, "ups_timer_start": { "name": "Load start timer" }, "ups_type": { "name": "UPS type" }, - "ups_watchdog_status": { "name": "Watchdog status" }, - "watts": { "name": "Watts" } + "ups_watchdog_status": { "name": "Watchdog status" } + }, + "switch": { + "outlet_number_load_poweronoff": { "name": "Power outlet {outlet_name}" } + } + }, + "exceptions": { + "data_fetch_error": { + "message": "Error fetching UPS state: {err}" + }, + "device_authentication": { + "message": "Device authentication error: {err}" + }, + "device_invalid": { + "message": "Unable to find a NUT device with ID {device_id}" + }, + "nut_command_error": { + "message": "Error running command {command_name}, {err}" } } } diff --git a/homeassistant/components/nut/switch.py b/homeassistant/components/nut/switch.py new file mode 100644 index 00000000000..924a596cc8e --- /dev/null +++ b/homeassistant/components/nut/switch.py @@ -0,0 +1,90 @@ +"""Provides a switch for switchable NUT outlets.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NutConfigEntry +from .entity import NUTBaseEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NutConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the NUT switches.""" + pynut_data = config_entry.runtime_data + coordinator = pynut_data.coordinator + status = coordinator.data + + # Dynamically add outlet switch types + if (num_outlets := status.get("outlet.count")) is None: + return + + data = pynut_data.data + unique_id = pynut_data.unique_id + user_available_commands = pynut_data.user_available_commands + switch_descriptions = [ + SwitchEntityDescription( + key=f"outlet.{outlet_num!s}.load.poweronoff", + translation_key="outlet_number_load_poweronoff", + translation_placeholders={ + "outlet_name": status.get(f"outlet.{outlet_num!s}.name") + or str(outlet_num) + }, + device_class=SwitchDeviceClass.OUTLET, + entity_registry_enabled_default=True, + ) + for outlet_num in range(1, int(num_outlets) + 1) + if ( + status.get(f"outlet.{outlet_num!s}.switchable") == "yes" + and f"outlet.{outlet_num!s}.load.on" in user_available_commands + and f"outlet.{outlet_num!s}.load.off" in user_available_commands + ) + ] + + async_add_entities( + NUTSwitch(coordinator, description, data, unique_id) + for description in switch_descriptions + ) + + +class NUTSwitch(NUTBaseEntity, SwitchEntity): + """Representation of a switch entity for NUT status values.""" + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + status = self.coordinator.data + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + if (state := status.get(f"{outlet}.{outlet_num_str}.status")) is None: + return None + return bool(state == "on") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + command_name = f"{outlet}.{outlet_num_str}.load.on" + await self.pynut_data.async_run_command(command_name) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + command_name = f"{outlet}.{outlet_num_str}.load.off" + await self.pynut_data.async_run_command(command_name) diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 4cfb3b85e0f..8a7631d8381 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -115,6 +115,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, unit_convert=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), NWSSensorEntityDescription( key="barometricPressure", diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 010b45e5a1c..e20eea0a61f 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -85,7 +85,8 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): raise err from None except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" if errors: diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py index e3e252cbf8b..c304bfdf72d 100644 --- a/homeassistant/components/ohme/__init__.py +++ b/homeassistant/components/ohme/__init__.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS @@ -31,7 +32,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool: """Set up Ohme from a config entry.""" - client = OhmeApiClient(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) + client = OhmeApiClient( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) try: await client.async_login() diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py index 6e942215c0f..41782ea4a2d 100644 --- a/homeassistant/components/ohme/button.py +++ b/homeassistant/components/ohme/button.py @@ -2,8 +2,9 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from typing import Any from ohme import ApiException, ChargerStatus, OhmeApiClient @@ -23,7 +24,7 @@ PARALLEL_UPDATES = 1 class OhmeButtonDescription(OhmeEntityDescription, ButtonEntityDescription): """Class describing Ohme button entities.""" - press_fn: Callable[[OhmeApiClient], Awaitable[None]] + press_fn: Callable[[OhmeApiClient], Coroutine[Any, Any, bool]] BUTTON_DESCRIPTIONS = [ diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index f0021808d92..786c615d68a 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/ohme/", "integration_type": "device", "iot_class": "cloud_polling", - "quality_scale": "silver", - "requirements": ["ohme==1.4.1"] + "quality_scale": "platinum", + "requirements": ["ohme==1.5.1"] } diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py index 0c71bab009f..f412c658085 100644 --- a/homeassistant/components/ohme/number.py +++ b/homeassistant/components/ohme/number.py @@ -1,7 +1,8 @@ """Platform for number.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from typing import Any from ohme import ApiException, OhmeApiClient @@ -22,7 +23,7 @@ PARALLEL_UPDATES = 1 class OhmeNumberDescription(OhmeEntityDescription, NumberEntityDescription): """Class describing Ohme number entities.""" - set_fn: Callable[[OhmeApiClient, float], Awaitable[None]] + set_fn: Callable[[OhmeApiClient, float], Coroutine[Any, Any, bool]] value_fn: Callable[[OhmeApiClient], float] @@ -31,7 +32,7 @@ NUMBER_DESCRIPTION = [ key="target_percentage", translation_key="target_percentage", value_fn=lambda client: client.target_soc, - set_fn=lambda client, value: client.async_set_target(target_percent=value), + set_fn=lambda client, value: client.async_set_target(target_percent=int(value)), native_min_value=0, native_max_value=100, native_step=1, @@ -42,7 +43,7 @@ NUMBER_DESCRIPTION = [ translation_key="preconditioning_duration", value_fn=lambda client: client.preconditioning, set_fn=lambda client, value: client.async_set_target( - pre_condition_length=value + pre_condition_length=int(value) ), native_min_value=0, native_max_value=60, diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml index f748cf339b4..2f7aece5bb6 100644 --- a/homeassistant/components/ohme/quality_scale.yaml +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -48,17 +48,20 @@ rules: status: exempt comment: | All supported devices are cloud connected over mobile data. Discovery is not possible. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo - dynamic-devices: todo - entity-category: todo + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Not supported by the API. Accounts and devices have a one-to-one relationship. + entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done @@ -67,8 +70,11 @@ rules: status: exempt comment: | This integration currently has no repairs. - stale-devices: todo + stale-devices: + status: exempt + comment: | + Not supported by the API. Accounts and devices have a one-to-one relationship. # Platinum - async-dependency: todo - inject-websession: todo - strict-typing: todo + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py index f065afeb176..d8d9c52c3b6 100644 --- a/homeassistant/components/ohme/select.py +++ b/homeassistant/components/ohme/select.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, Final @@ -24,7 +24,7 @@ PARALLEL_UPDATES = 1 class OhmeSelectDescription(OhmeEntityDescription, SelectEntityDescription): """Class to describe an Ohme select entity.""" - select_fn: Callable[[OhmeApiClient, Any], Awaitable[None]] + select_fn: Callable[[OhmeApiClient, Any], Coroutine[Any, Any, bool | None]] options: list[str] | None = None options_fn: Callable[[OhmeApiClient], list[str]] | None = None current_option_fn: Callable[[OhmeApiClient], str | None] diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index d0425040b53..7047e33749f 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0 class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription): """Class describing Ohme sensor entities.""" - value_fn: Callable[[OhmeApiClient], str | int | float] + value_fn: Callable[[OhmeApiClient], str | int | float | None] SENSOR_CHARGE_SESSION = [ @@ -99,6 +99,7 @@ SENSOR_ADVANCED_SETTINGS = [ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, value_fn=lambda client: client.power.ct_amps, is_supported_fn=lambda client: client.ct_connected, + entity_registry_enabled_default=False, ), ] @@ -129,6 +130,6 @@ class OhmeSensor(OhmeEntity, SensorEntity): entity_description: OhmeSensorDescription @property - def native_value(self) -> str | int | float: + def native_value(self) -> str | int | float | None: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.client) diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py index be044f01740..8ed29aa373d 100644 --- a/homeassistant/components/ohme/services.py +++ b/homeassistant/components/ohme/services.py @@ -5,7 +5,7 @@ from typing import Final from ohme import OhmeApiClient import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -16,6 +16,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import selector from .const import DOMAIN +from .coordinator import OhmeConfigEntry ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_PRICE_CAP: Final = "price_cap" @@ -47,7 +48,7 @@ SERVICE_SET_PRICE_CAP_SCHEMA: Final = vol.Schema( def __get_client(call: ServiceCall) -> OhmeApiClient: """Get the client from the config entry.""" entry_id: str = call.data[ATTR_CONFIG_ENTRY] - entry: ConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id) + entry: OhmeConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id) if not entry: raise ServiceValidationError( @@ -78,7 +79,7 @@ def async_setup_services(hass: HomeAssistant) -> None: """List of charge slots.""" client = __get_client(service_call) - return {"slots": client.slots} + return {"slots": [slot.to_dict() for slot in client.slots]} async def set_price_cap( service_call: ServiceCall, diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 4a2170babeb..fa19adbede8 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -89,7 +89,7 @@ "state": { "smart_charge": "Smart charge", "max_charge": "Max charge", - "paused": "Paused" + "paused": "[%key:common::state::paused%]" } }, "vehicle": { @@ -100,8 +100,8 @@ "status": { "name": "Status", "state": { - "unplugged": "Unplugged", - "plugged_in": "Plugged in", + "unplugged": "[%key:component::binary_sensor::entity_component::plug::state::off%]", + "plugged_in": "[%key:component::binary_sensor::entity_component::plug::state::on%]", "charging": "[%key:common::state::charging%]", "paused": "[%key:common::state::paused%]", "pending_approval": "Pending approval", diff --git a/homeassistant/components/ohme/time.py b/homeassistant/components/ohme/time.py index 264b2afd41a..a0b1edb594a 100644 --- a/homeassistant/components/ohme/time.py +++ b/homeassistant/components/ohme/time.py @@ -1,8 +1,9 @@ """Platform for time.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import time +from typing import Any from ohme import ApiException, OhmeApiClient @@ -22,7 +23,7 @@ PARALLEL_UPDATES = 1 class OhmeTimeDescription(OhmeEntityDescription, TimeEntityDescription): """Class describing Ohme time entities.""" - set_fn: Callable[[OhmeApiClient, time], Awaitable[None]] + set_fn: Callable[[OhmeApiClient, time], Coroutine[Any, Any, bool]] value_fn: Callable[[OhmeApiClient], time] diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 5f1d908f7f8..978e16963d9 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -31,7 +31,7 @@ from homeassistant.helpers import area_registry as ar from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations -from homeassistant.setup import async_setup_component +from homeassistant.setup import SetupPhases, async_pause_setup, async_setup_component if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -51,7 +51,7 @@ async def async_setup( hass: HomeAssistant, data: OnboardingStoreData, store: OnboardingStorage ) -> None: """Set up the onboarding view.""" - hass.http.register_view(OnboardingView(data, store)) + hass.http.register_view(OnboardingStatusView(data, store)) hass.http.register_view(InstallationTypeOnboardingView(data)) hass.http.register_view(UserOnboardingView(data, store)) hass.http.register_view(CoreConfigOnboardingView(data, store)) @@ -60,19 +60,33 @@ async def async_setup( hass.http.register_view(BackupInfoView(data)) hass.http.register_view(RestoreBackupView(data)) hass.http.register_view(UploadBackupView(data)) + await setup_cloud_views(hass, data) -class OnboardingView(HomeAssistantView): - """Return the onboarding status.""" +class _BaseOnboardingView(HomeAssistantView): + """Base class for onboarding views.""" + + def __init__(self, data: OnboardingStoreData) -> None: + """Initialize the onboarding view.""" + self._data = data + + +class _NoAuthBaseOnboardingView(_BaseOnboardingView): + """Base class for unauthenticated onboarding views.""" requires_auth = False + + +class OnboardingStatusView(_NoAuthBaseOnboardingView): + """Return the onboarding status.""" + url = "/api/onboarding" name = "api:onboarding" def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None: """Initialize the onboarding view.""" + super().__init__(data) self._store = store - self._data = data async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" @@ -81,17 +95,12 @@ class OnboardingView(HomeAssistantView): ) -class InstallationTypeOnboardingView(HomeAssistantView): +class InstallationTypeOnboardingView(_NoAuthBaseOnboardingView): """Return the installation type during onboarding.""" - requires_auth = False url = "/api/onboarding/installation_type" name = "api:onboarding:installation_type" - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the onboarding installation type view.""" - self._data = data - async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" if self._data["done"]: @@ -102,15 +111,15 @@ class InstallationTypeOnboardingView(HomeAssistantView): return self.json({"installation_type": info["installation_type"]}) -class _BaseOnboardingView(HomeAssistantView): - """Base class for onboarding.""" +class _BaseOnboardingStepView(_BaseOnboardingView): + """Base class for an onboarding step.""" step: str def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None: """Initialize the onboarding view.""" + super().__init__(data) self._store = store - self._data = data self._lock = asyncio.Lock() @callback @@ -130,7 +139,7 @@ class _BaseOnboardingView(HomeAssistantView): listener() -class UserOnboardingView(_BaseOnboardingView): +class UserOnboardingView(_BaseOnboardingStepView): """View to handle create user onboarding step.""" url = "/api/onboarding/users" @@ -196,7 +205,7 @@ class UserOnboardingView(_BaseOnboardingView): return self.json({"auth_code": auth_code}) -class CoreConfigOnboardingView(_BaseOnboardingView): +class CoreConfigOnboardingView(_BaseOnboardingStepView): """View to finish core config onboarding step.""" url = "/api/onboarding/core_config" @@ -242,7 +251,7 @@ class CoreConfigOnboardingView(_BaseOnboardingView): return self.json({}) -class IntegrationOnboardingView(_BaseOnboardingView): +class IntegrationOnboardingView(_BaseOnboardingStepView): """View to finish integration onboarding step.""" url = "/api/onboarding/integration" @@ -289,7 +298,7 @@ class IntegrationOnboardingView(_BaseOnboardingView): return self.json({"auth_code": auth_code}) -class AnalyticsOnboardingView(_BaseOnboardingView): +class AnalyticsOnboardingView(_BaseOnboardingStepView): """View to finish analytics onboarding step.""" url = "/api/onboarding/analytics" @@ -311,17 +320,7 @@ class AnalyticsOnboardingView(_BaseOnboardingView): return self.json({}) -class BackupOnboardingView(HomeAssistantView): - """Backup onboarding view.""" - - requires_auth = False - - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the view.""" - self._data = data - - -def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( +def with_backup_manager[_ViewT: _BaseOnboardingView, **_P]( func: Callable[ Concatenate[_ViewT, BackupManager, web.Request, _P], Coroutine[Any, Any, web.Response], @@ -353,7 +352,7 @@ def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( return with_backup -class BackupInfoView(BackupOnboardingView): +class BackupInfoView(_NoAuthBaseOnboardingView): """Get backup info view.""" url = "/api/onboarding/backup/info" @@ -372,7 +371,7 @@ class BackupInfoView(BackupOnboardingView): ) -class RestoreBackupView(BackupOnboardingView): +class RestoreBackupView(_NoAuthBaseOnboardingView): """Restore backup view.""" url = "/api/onboarding/backup/restore" @@ -417,7 +416,7 @@ class RestoreBackupView(BackupOnboardingView): return web.Response(status=HTTPStatus.OK) -class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): +class UploadBackupView(_NoAuthBaseOnboardingView, backup_http.UploadBackupView): """Upload backup view.""" url = "/api/onboarding/backup/upload" @@ -429,6 +428,116 @@ class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): return await self._post(request) +async def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: + """Set up the cloud views.""" + + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # Import the cloud integration in an executor to avoid blocking the + # event loop. + def import_cloud() -> None: + """Import the cloud integration.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.cloud import http_api # noqa: F401 + + await hass.async_add_import_executor_job(import_cloud) + + # The cloud integration is imported locally to avoid cloud being imported by + # bootstrap.py and to avoid circular imports. + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.cloud import http_api as cloud_http + + # pylint: disable-next=import-outside-toplevel,hass-component-root-import + from homeassistant.components.cloud.const import DATA_CLOUD + + def with_cloud[_ViewT: _BaseOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], + ) -> Callable[ + Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response] + ]: + """Home Assistant API decorator to check onboarding and cloud.""" + + @wraps(func) + async def _with_cloud( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check onboarding status, cloud and call function.""" + if self._data["done"]: + # If at least one onboarding step is done, we don't allow accessing + # the cloud onboarding views. + raise HTTPUnauthorized + + hass = request.app[KEY_HASS] + if DATA_CLOUD not in hass.data: + return self.json( + {"code": "cloud_disabled"}, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return await func(self, request, *args, **kwargs) + + return _with_cloud + + class CloudForgotPasswordView( + _NoAuthBaseOnboardingView, cloud_http.CloudForgotPasswordView + ): + """View to start Forgot Password flow.""" + + url = "/api/onboarding/cloud/forgot_password" + name = "api:onboarding:cloud:forgot_password" + + @with_cloud + async def post(self, request: web.Request) -> web.Response: + """Handle forgot password request.""" + return await super()._post(request) + + class CloudLoginView(_NoAuthBaseOnboardingView, cloud_http.CloudLoginView): + """Login to Home Assistant Cloud.""" + + url = "/api/onboarding/cloud/login" + name = "api:onboarding:cloud:login" + + @with_cloud + async def post(self, request: web.Request) -> web.Response: + """Handle login request.""" + return await super()._post(request) + + class CloudLogoutView(_NoAuthBaseOnboardingView, cloud_http.CloudLogoutView): + """Log out of the Home Assistant cloud.""" + + url = "/api/onboarding/cloud/logout" + name = "api:onboarding:cloud:logout" + + @with_cloud + async def post(self, request: web.Request) -> web.Response: + """Handle logout request.""" + return await super()._post(request) + + class CloudStatusView(_NoAuthBaseOnboardingView): + """Get cloud status view.""" + + url = "/api/onboarding/cloud/status" + name = "api:onboarding:cloud:status" + + @with_cloud + async def get(self, request: web.Request) -> web.Response: + """Return cloud status.""" + hass = request.app[KEY_HASS] + cloud = hass.data[DATA_CLOUD] + return self.json({"logged_in": cloud.is_logged_in}) + + hass.http.register_view(CloudForgotPasswordView(data)) + hass.http.register_view(CloudLoginView(data)) + hass.http.register_view(CloudLogoutView(data)) + hass.http.register_view(CloudStatusView(data)) + + @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index 7b2dbaab87a..3eb7d762712 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -88,8 +88,8 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): ), translation_key=key, translation_placeholders={ - "total": str(drive.quota.total), - "used": str(drive.quota.used), + "total": f"{drive.quota.total / (1024**3):.2f}", + "used": f"{drive.quota.used / (1024**3):.2f}", }, ) return drive diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 46f41503d97..5e7719673b1 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -140,14 +140,14 @@ "device_selection": "[%key:component::onewire::options::error::device_not_selected%]" }, "description": "Select what configuration steps to process", - "title": "OneWire Device Options" + "title": "1-Wire device options" }, "configure_device": { "data": { - "precision": "Sensor Precision" + "precision": "Sensor precision" }, "description": "Select sensor precision for {sensor_id}", - "title": "OneWire Sensor Precision" + "title": "1-Wire sensor precision" } } } diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 5d941be959a..85ff0de3251 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import section from homeassistant.helpers.selector import ( @@ -30,8 +30,6 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( - CONF_RECEIVER_MAX_VOLUME, - CONF_SOURCES, DOMAIN, OPTION_INPUT_SOURCES, OPTION_LISTENING_MODES, @@ -329,61 +327,6 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle reconfiguration of the receiver.""" return await self.async_step_manual() - async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """Import the yaml config.""" - _LOGGER.debug("Import flow user input: %s", user_input) - - host: str = user_input[CONF_HOST] - name: str | None = user_input.get(CONF_NAME) - user_max_volume: int = user_input[OPTION_MAX_VOLUME] - user_volume_resolution: int = user_input[CONF_RECEIVER_MAX_VOLUME] - user_sources: dict[InputSource, str] = user_input[CONF_SOURCES] - - info: ReceiverInfo | None = user_input.get("info") - if info is None: - try: - info = await async_interview(host) - except Exception: - _LOGGER.exception("Import flow interview error for host %s", host) - return self.async_abort(reason="cannot_connect") - - if info is None: - _LOGGER.error("Import flow interview error for host %s", host) - return self.async_abort(reason="cannot_connect") - - unique_id = info.identifier - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - - name = name or info.model_name - - volume_resolution = VOLUME_RESOLUTION_ALLOWED[-1] - for volume_resolution_allowed in VOLUME_RESOLUTION_ALLOWED: - if user_volume_resolution <= volume_resolution_allowed: - volume_resolution = volume_resolution_allowed - break - - max_volume = min( - 100, user_max_volume * user_volume_resolution / volume_resolution - ) - - sources_store: dict[str, str] = {} - for source, source_name in user_sources.items(): - sources_store[source.value] = source_name - - return self.async_create_entry( - title=name, - data={ - CONF_HOST: host, - }, - options={ - OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: max_volume, - OPTION_INPUT_SOURCES: sources_store, - OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, - }, - ) - @staticmethod @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py index fcb1a8a0a9e..851d80c5100 100644 --- a/homeassistant/components/onkyo/const.py +++ b/homeassistant/components/onkyo/const.py @@ -11,9 +11,6 @@ DOMAIN = "onkyo" DEVICE_INTERVIEW_TIMEOUT = 5 DEVICE_DISCOVERY_TIMEOUT = 5 -CONF_SOURCES = "sources" -CONF_RECEIVER_MAX_VOLUME = "receiver_max_volume" - type VolumeResolution = Literal[50, 80, 100, 200] OPTION_VOLUME_RESOLUTION = "volume_resolution" OPTION_VOLUME_RESOLUTION_DEFAULT: VolumeResolution = 50 diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index f7fe83c57a3..aed7c51af80 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -8,32 +8,18 @@ from functools import cache import logging from typing import Any, Literal -import voluptuous as vol - from homeassistant.components.media_player import ( - PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OnkyoConfigEntry from .const import ( - CONF_RECEIVER_MAX_VOLUME, - CONF_SOURCES, DOMAIN, OPTION_MAX_VOLUME, OPTION_VOLUME_RESOLUTION, @@ -43,46 +29,11 @@ from .const import ( ListeningMode, VolumeResolution, ) -from .receiver import Receiver, async_discover +from .receiver import Receiver from .services import DATA_MP_ENTITIES _LOGGER = logging.getLogger(__name__) -CONF_MAX_VOLUME_DEFAULT = 100 -CONF_RECEIVER_MAX_VOLUME_DEFAULT = 80 -CONF_SOURCES_DEFAULT = { - "tv": "TV", - "bd": "Bluray", - "game": "Game", - "aux1": "Aux1", - "video1": "Video 1", - "video2": "Video 2", - "video3": "Video 3", - "video4": "Video 4", - "video5": "Video 5", - "video6": "Video 6", - "video7": "Video 7", - "fm": "Radio", -} - -ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" - -PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(OPTION_MAX_VOLUME, default=CONF_MAX_VOLUME_DEFAULT): vol.All( - vol.Coerce(int), vol.Range(min=1, max=100) - ), - vol.Optional( - CONF_RECEIVER_MAX_VOLUME, default=CONF_RECEIVER_MAX_VOLUME_DEFAULT - ): cv.positive_int, - vol.Optional(CONF_SOURCES, default=CONF_SOURCES_DEFAULT): { - cv.string: cv.string - }, - } -) - SUPPORTED_FEATURES_BASE = ( MediaPlayerEntityFeature.TURN_ON @@ -194,122 +145,6 @@ def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode] return {value: key for key, value in _listening_mode_lib_mappings(zone).items()} -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import config from yaml.""" - host = config.get(CONF_HOST) - - source_mapping: dict[str, InputSource] = {} - for zone in ZONES: - for source, source_lib in _input_source_lib_mappings(zone).items(): - if isinstance(source_lib, str): - source_mapping.setdefault(source_lib, source) - else: - for source_lib_single in source_lib: - source_mapping.setdefault(source_lib_single, source) - - sources: dict[InputSource, str] = {} - for source_lib_single, source_name in config[CONF_SOURCES].items(): - user_source = source_mapping.get(source_lib_single.lower()) - if user_source is not None: - sources[user_source] = source_name - - config[CONF_SOURCES] = sources - - results = [] - if host is not None: - _LOGGER.debug("Importing yaml single: %s", host) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - results.append((host, result)) - else: - for info in await async_discover(): - host = info.host - - # Migrate legacy entities. - registry = er.async_get(hass) - old_unique_id = f"{info.model_name}_{info.identifier}" - new_unique_id = f"{info.identifier}_main" - entity_id = registry.async_get_entity_id( - "media_player", DOMAIN, old_unique_id - ) - if entity_id is not None: - _LOGGER.debug( - "Migrating unique_id from [%s] to [%s] for entity %s", - old_unique_id, - new_unique_id, - entity_id, - ) - registry.async_update_entity(entity_id, new_unique_id=new_unique_id) - - _LOGGER.debug("Importing yaml discover: %s", info.host) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config | {CONF_HOST: info.host} | {"info": info}, - ) - results.append((host, result)) - - _LOGGER.debug("Importing yaml results: %s", results) - if not results: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_import_issue_no_discover", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_no_discover", - translation_placeholders={"url": ISSUE_URL_PLACEHOLDER}, - ) - - all_successful = True - for host, result in results: - if ( - result.get("type") == FlowResultType.CREATE_ENTRY - or result.get("reason") == "already_configured" - ): - continue - if error := result.get("reason"): - all_successful = False - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{host}_{error}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{error}", - translation_placeholders={ - "host": host, - "url": ISSUE_URL_PLACEHOLDER, - }, - ) - - if all_successful: - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - breaks_in_ha_version="2025.5.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "onkyo", - }, - ) - - async def async_setup_entry( hass: HomeAssistant, entry: OnkyoConfigEntry, diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index d8131dd1149..3e5520c79f7 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -83,16 +83,6 @@ "empty_listening_mode_list": "Listening mode list cannot be empty" } }, - "issues": { - "deprecated_yaml_import_issue_no_discover": { - "title": "The Onkyo YAML configuration import failed", - "description": "Configuring Onkyo using YAML is being removed but no receivers were discovered when importing your YAML configuration.\n\nEnsure the connection to the receiver works and restart Home Assistant to try again or remove the Onkyo YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, - "deprecated_yaml_import_issue_cannot_connect": { - "title": "The Onkyo YAML configuration import failed", - "description": "Configuring Onkyo using YAML is being removed but there was a connection error when importing your YAML configuration for host {host}.\n\nEnsure the connection to the receiver works and restart Home Assistant to try again or remove the Onkyo YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - } - }, "exceptions": { "invalid_sound_mode": { "message": "Cannot select sound mode \"{invalid_sound_mode}\" for entity: {entity_id}." diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index fcf6ab298dc..276f5ddea3b 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -11,6 +11,7 @@ from openai.types.images_response import ImagesResponse from openai.types.responses import ( EasyInputMessageParam, Response, + ResponseInputFileParam, ResponseInputImageParam, ResponseInputMessageContentListParam, ResponseInputParam, @@ -132,19 +133,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not Path(filename).exists(): raise HomeAssistantError(f"`{filename}` does not exist") mime_type, base64_file = encode_file(filename) - if "image/" not in mime_type: + if "image/" in mime_type: + content.append( + ResponseInputImageParam( + type="input_image", + file_id=filename, + image_url=f"data:{mime_type};base64,{base64_file}", + detail="auto", + ) + ) + elif "application/pdf" in mime_type: + content.append( + ResponseInputFileParam( + type="input_file", + filename=filename, + file_data=f"data:{mime_type};base64,{base64_file}", + ) + ) + else: raise HomeAssistantError( - "Only images are supported by the OpenAI API," - f"`{filename}` is not an image file" + "Only images and PDF are supported by the OpenAI API," + f"`{filename}` is not an image file or PDF" ) - content.append( - ResponseInputImageParam( - type="input_image", - file_id=filename, - image_url=f"data:{mime_type};base64,{base64_file}", - detail="auto", - ) - ) if CONF_FILENAMES in call.data: await hass.async_add_executor_job(append_files_to_content) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index c631884ea0b..7304eb52da3 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -2,22 +2,31 @@ from __future__ import annotations +import json import logging from types import MappingProxyType from typing import Any import openai import voluptuous as vol +from voluptuous_openapi import convert +from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_API_KEY, + CONF_LLM_HASS_API, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import llm +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, @@ -37,12 +46,22 @@ from .const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + RECOMMENDED_WEB_SEARCH, + RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, + RECOMMENDED_WEB_SEARCH_USER_LOCATION, UNSUPPORTED_MODELS, ) @@ -66,7 +85,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - client = openai.AsyncOpenAI(api_key=data[CONF_API_KEY]) + client = openai.AsyncOpenAI( + api_key=data[CONF_API_KEY], http_client=get_async_client(hass) + ) await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) @@ -137,7 +158,16 @@ class OpenAIOptionsFlow(OptionsFlow): if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: errors[CONF_CHAT_MODEL] = "model_not_supported" - else: + + if user_input.get(CONF_WEB_SEARCH): + if not user_input.get( + CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL + ).startswith("gpt-4o"): + errors[CONF_WEB_SEARCH] = "web_search_not_supported" + elif user_input.get(CONF_WEB_SEARCH_USER_LOCATION): + user_input.update(await self.get_location_data()) + + if not errors: return self.async_create_entry(title="", data=user_input) else: # Re-render the options again, now with the recommended options shown/hidden @@ -156,6 +186,59 @@ class OpenAIOptionsFlow(OptionsFlow): errors=errors, ) + async def get_location_data(self) -> dict[str, str]: + """Get approximate location data of the user.""" + location_data: dict[str, str] = {} + zone_home = self.hass.states.get(ENTITY_ID_HOME) + if zone_home is not None: + client = openai.AsyncOpenAI( + api_key=self.config_entry.data[CONF_API_KEY], + http_client=get_async_client(self.hass), + ) + location_schema = vol.Schema( + { + vol.Optional( + CONF_WEB_SEARCH_CITY, + description="Free text input for the city, e.g. `San Francisco`", + ): str, + vol.Optional( + CONF_WEB_SEARCH_REGION, + description="Free text input for the region, e.g. `California`", + ): str, + } + ) + response = await client.responses.create( + model=RECOMMENDED_CHAT_MODEL, + input=[ + { + "role": "system", + "content": "Where are the following coordinates located: " + f"({zone_home.attributes[ATTR_LATITUDE]}," + f" {zone_home.attributes[ATTR_LONGITUDE]})?", + } + ], + text={ + "format": { + "type": "json_schema", + "name": "approximate_location", + "description": "Approximate location data of the user " + "for refined web search results", + "schema": convert(location_schema), + "strict": False, + } + }, + store=False, + ) + location_data = location_schema(json.loads(response.output_text) or {}) + + if self.hass.config.country: + location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country + location_data[CONF_WEB_SEARCH_TIMEZONE] = self.hass.config.time_zone + + _LOGGER.debug("Location data: %s", location_data) + + return location_data + def openai_config_option_schema( hass: HomeAssistant, @@ -227,10 +310,35 @@ def openai_config_option_schema( ): SelectSelector( SelectSelectorConfig( options=["low", "medium", "high"], - translation_key="reasoning_effort", + translation_key=CONF_REASONING_EFFORT, mode=SelectSelectorMode.DROPDOWN, ) ), + vol.Optional( + CONF_WEB_SEARCH, + description={"suggested_value": options.get(CONF_WEB_SEARCH)}, + default=RECOMMENDED_WEB_SEARCH, + ): bool, + vol.Optional( + CONF_WEB_SEARCH_CONTEXT_SIZE, + description={ + "suggested_value": options.get(CONF_WEB_SEARCH_CONTEXT_SIZE) + }, + default=RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key=CONF_WEB_SEARCH_CONTEXT_SIZE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional( + CONF_WEB_SEARCH_USER_LOCATION, + description={ + "suggested_value": options.get(CONF_WEB_SEARCH_USER_LOCATION) + }, + default=RECOMMENDED_WEB_SEARCH_USER_LOCATION, + ): bool, } ) return schema diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index c9987cb81b9..41abc504219 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -14,11 +14,21 @@ CONF_REASONING_EFFORT = "reasoning_effort" CONF_RECOMMENDED = "recommended" CONF_TEMPERATURE = "temperature" CONF_TOP_P = "top_p" +CONF_WEB_SEARCH = "web_search" +CONF_WEB_SEARCH_USER_LOCATION = "user_location" +CONF_WEB_SEARCH_CONTEXT_SIZE = "search_context_size" +CONF_WEB_SEARCH_CITY = "city" +CONF_WEB_SEARCH_REGION = "region" +CONF_WEB_SEARCH_COUNTRY = "country" +CONF_WEB_SEARCH_TIMEZONE = "timezone" RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" RECOMMENDED_MAX_TOKENS = 150 RECOMMENDED_REASONING_EFFORT = "low" RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TOP_P = 1.0 +RECOMMENDED_WEB_SEARCH = False +RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium" +RECOMMENDED_WEB_SEARCH_USER_LOCATION = False UNSUPPORTED_MODELS: list[str] = [ "o1-mini", diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 7a8830ffd95..026e18f3ce1 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -9,18 +9,24 @@ from openai._streaming import AsyncStream from openai.types.responses import ( EasyInputMessageParam, FunctionToolParam, + ResponseCompletedEvent, + ResponseErrorEvent, + ResponseFailedEvent, ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionCallArgumentsDoneEvent, ResponseFunctionToolCall, ResponseFunctionToolCallParam, + ResponseIncompleteEvent, ResponseInputParam, ResponseOutputItemAddedEvent, ResponseOutputMessage, ResponseStreamEvent, ResponseTextDeltaEvent, ToolParam, + WebSearchToolParam, ) from openai.types.responses.response_input_param import FunctionCallOutput +from openai.types.responses.web_search_tool_param import UserLocation from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation @@ -39,6 +45,13 @@ from .const import ( CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, @@ -46,6 +59,7 @@ from .const import ( RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, ) # Max number of back and forth with the LLM to generate a response @@ -99,8 +113,7 @@ def _convert_content_to_param( if isinstance(content, conversation.AssistantContent) and content.tool_calls: messages.extend( - # https://github.com/openai/openai-python/issues/2205 - ResponseFunctionToolCallParam( # type: ignore[typeddict-item] + ResponseFunctionToolCallParam( type="function_call", name=tool_call.tool_name, arguments=json.dumps(tool_call.tool_args), @@ -112,6 +125,7 @@ def _convert_content_to_param( async def _transform_stream( + chat_log: conversation.ChatLog, result: AsyncStream[ResponseStreamEvent], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform an OpenAI delta stream into HA format.""" @@ -138,6 +152,57 @@ async def _transform_stream( ) ] } + elif isinstance(event, ResponseCompletedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + elif isinstance(event, ResponseIncompleteEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + + if ( + event.response.incomplete_details + and event.response.incomplete_details.reason + ): + reason: str = event.response.incomplete_details.reason + else: + reason = "unknown reason" + + if reason == "max_output_tokens": + reason = "max output tokens reached" + elif reason == "content_filter": + reason = "content filter triggered" + + raise HomeAssistantError(f"OpenAI response incomplete: {reason}") + elif isinstance(event, ResponseFailedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + reason = "unknown reason" + if event.response.error is not None: + reason = event.response.error.message + raise HomeAssistantError(f"OpenAI response failed: {reason}") + elif isinstance(event, ResponseErrorEvent): + raise HomeAssistantError(f"OpenAI response error: {event.message}") class OpenAIConversationEntity( @@ -210,6 +275,25 @@ class OpenAIConversationEntity( for tool in chat_log.llm_api.tools ] + if options.get(CONF_WEB_SEARCH): + web_search = WebSearchToolParam( + type="web_search_preview", + search_context_size=options.get( + CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE + ), + ) + if options.get(CONF_WEB_SEARCH_USER_LOCATION): + web_search["user_location"] = UserLocation( + type="approximate", + city=options.get(CONF_WEB_SEARCH_CITY, ""), + region=options.get(CONF_WEB_SEARCH_REGION, ""), + country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), + timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), + ) + if tools is None: + tools = [] + tools.append(web_search) + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) messages = [ m @@ -253,7 +337,7 @@ class OpenAIConversationEntity( raise HomeAssistantError("Error talking to OpenAI") from err async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(result) + user_input.agent_id, _transform_stream(chat_log, result) ): messages.extend(_convert_content_to_param(content)) diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index a4e46f6457b..988dd2321d5 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.66.3"] + "requirements": ["openai==1.68.2"] } diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index c9d7ee112bd..42baf40d470 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -24,24 +24,38 @@ "top_p": "Top P", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", "recommended": "Recommended model settings", - "reasoning_effort": "Reasoning effort" + "reasoning_effort": "Reasoning effort", + "web_search": "Enable web search", + "search_context_size": "Search context size", + "user_location": "Include home location" }, "data_description": { "prompt": "Instruct how the LLM should respond. This can be a template.", - "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)" + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)", + "web_search": "Allow the model to search the web for the latest information before generating a response", + "search_context_size": "High level guidance for the amount of context window space to use for the search", + "user_location": "Refine search results based on geography" } } }, "error": { - "model_not_supported": "This model is not supported, please select a different model" + "model_not_supported": "This model is not supported, please select a different model", + "web_search_not_supported": "Web search is only supported for gpt-4o and gpt-4o-mini models" } }, "selector": { "reasoning_effort": { "options": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } + }, + "search_context_size": { + "options": { + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, @@ -75,7 +89,7 @@ }, "generate_content": { "name": "Generate content", - "description": "Sends a conversational query to ChatGPT including any attached image files", + "description": "Sends a conversational query to ChatGPT including any attached image or PDF files", "fields": { "config_entry": { "name": "Config entry", diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index cc57a7d9e0c..ae1a1eb9276 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -172,8 +172,8 @@ "vcc": "Vcc (5V)", "led_e": "LED E", "led_f": "LED F", - "home": "Home", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", "ds1820": "DS1820", "dhw_block": "Block hot water" } diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index 9349d2cc116..f3b9aa686d5 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -54,10 +54,10 @@ "name": "Current UV level", "state": { "extreme": "Extreme", - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "very_high": "Very high" + "very_high": "[%key:common::state::very_high%]" } }, "max_uv_index": { diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 0afab69b638..a595652d90b 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -89,7 +89,8 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=ATTR_API_WIND_BEARING, name="Wind bearing", native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key=ATTR_API_HUMIDITY, diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index aed89ccf46e..e8b6dbf9718 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -16,7 +16,11 @@ from opower import ( from opower.exceptions import ApiException, CannotConnect, InvalidAuth from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -201,7 +205,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): f"{account.meter_type.name.lower()} {account.utility_account_id}" ) cost_metadata = StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{name_prefix} cost", source=DOMAIN, @@ -209,7 +213,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): unit_of_measurement=None, ) consumption_metadata = StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{name_prefix} consumption", source=DOMAIN, diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 2da4511c0aa..e691d01257a 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.9.0"] + "requirements": ["opower==0.10.0"] } diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 362e6cd7596..749545743fe 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -11,7 +11,7 @@ "mfa": { "description": "The TOTP secret below is not one of the 6 digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", "data": { - "totp_secret": "TOTP Secret" + "totp_secret": "TOTP secret" } }, "reauth_confirm": { @@ -19,7 +19,7 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "totp_secret": "TOTP Secret" + "totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]" } } }, diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index 7e10168d941..ef7e2abb89b 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -60,7 +60,7 @@ "ffr": "Fast frequency reserve", "legionella": "Legionella", "manual": "Manual", - "off": "Off", + "off": "[%key:common::state::off%]", "powersave": "Power save", "voltage": "Voltage" } @@ -70,7 +70,7 @@ "state": { "advanced": "Advanced", "gridcompany": "Grid company", - "off": "Off", + "off": "[%key:common::state::off%]", "oso": "OSO", "smartcompany": "Smart company" } diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 70857f0ba11..937b4ccb937 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.3"], + "requirements": ["pyoverkiz==1.16.5"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 9214398a37b..cec0d0d2571 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -70,6 +70,15 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ options=["full", "normal", "medium", "low", "verylow"], translation_key="battery", ), + OverkizSensorDescription( + key=OverkizState.CORE_BATTERY_DISCRETE_LEVEL, + name="Battery", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:battery", + device_class=SensorDeviceClass.ENUM, + options=["good", "medium", "low", "critical"], + translation_key="battery", + ), OverkizSensorDescription( key=OverkizState.CORE_RSSI_LEVEL, name="RSSI level", diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 0c564a003d6..da6c01219f1 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -120,18 +120,20 @@ "battery": { "state": { "full": "Full", - "low": "Low", - "normal": "Normal", - "medium": "Medium", - "verylow": "Very low" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]", + "medium": "[%key:common::state::medium%]", + "verylow": "[%key:common::state::very_low%]", + "good": "Good", + "critical": "Critical" } }, "discrete_rssi_level": { "state": { "good": "Good", - "low": "Low", - "normal": "Normal", - "verylow": "Very low" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]", + "verylow": "[%key:common::state::very_low%]" } }, "priority_lock_originator": { diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py index 9895ea84c2c..2960cefe10c 100644 --- a/homeassistant/components/overkiz/water_heater/__init__.py +++ b/homeassistant/components/overkiz/water_heater/__init__.py @@ -13,6 +13,9 @@ from ..entity import OverkizEntity from .atlantic_domestic_hot_water_production_mlb_component import ( AtlanticDomesticHotWaterProductionMBLComponent, ) +from .atlantic_domestic_hot_water_production_v2_io_component import ( + AtlanticDomesticHotWaterProductionV2IOComponent, +) from .atlantic_pass_apc_dhw import AtlanticPassAPCDHW from .domestic_hot_water_production import DomesticHotWaterProduction from .hitachi_dhw import HitachiDHW @@ -52,4 +55,5 @@ WIDGET_TO_WATER_HEATER_ENTITY = { CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY = { "modbuslink:AtlanticDomesticHotWaterProductionMBLComponent": AtlanticDomesticHotWaterProductionMBLComponent, + "io:AtlanticDomesticHotWaterProductionV2_CV4E_IOComponent": AtlanticDomesticHotWaterProductionV2IOComponent, } diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py new file mode 100644 index 00000000000..7e7db07f847 --- /dev/null +++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py @@ -0,0 +1,332 @@ +"""Support for AtlanticDomesticHotWaterProductionV2IOComponent.""" + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..entity import OverkizEntity + +DEFAULT_MIN_TEMP: float = 50.0 +DEFAULT_MAX_TEMP: float = 62.0 +MAX_BOOST_MODE_DURATION: int = 7 + +DHWP_AWAY_MODES = [ + OverkizCommandParam.ABSENCE, + OverkizCommandParam.AWAY, + OverkizCommandParam.FROSTPROTECTION, +] + + +class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeaterEntity): + """Representation of AtlanticDomesticHotWaterProductionV2IOComponent (io).""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + | WaterHeaterEntityFeature.AWAY_MODE + | WaterHeaterEntityFeature.ON_OFF + ) + _attr_operation_list = [ + STATE_ECO, + STATE_PERFORMANCE, + STATE_HEAT_PUMP, + STATE_ELECTRIC, + ] + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + + min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE] + if min_temp: + return cast(float, min_temp.value_as_float) + return DEFAULT_MIN_TEMP + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + + max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE] + if max_temp: + return cast(float, max_temp.value_as_float) + return DEFAULT_MAX_TEMP + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + + return cast( + float, + self.executor.select_state( + OverkizState.IO_MIDDLE_WATER_TEMPERATURE, + ), + ) + + @property + def target_temperature(self) -> float: + """Return the temperature corresponding to the PRESET.""" + + return cast( + float, + self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + + temperature = kwargs.get(ATTR_TEMPERATURE) + await self.executor.async_execute_command( + OverkizCommand.SET_TARGET_TEMPERATURE, temperature, refresh_afterwards=False + ) + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + await self.coordinator.async_refresh() + + @property + def is_state_eco(self) -> bool: + """Return true if eco mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.MANUAL_ECO_ACTIVE + ) + + @property + def is_state_performance(self) -> bool: + """Return true if performance mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.AUTO_MODE + ) + + @property + def is_state_heat_pump(self) -> bool: + """Return true if heat pump mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.MANUAL_ECO_INACTIVE + ) + + @property + def is_away_mode_on(self) -> bool: + """Return true if away mode is on.""" + + away_mode_duration = cast( + str, self.executor.select_state(OverkizState.IO_AWAY_MODE_DURATION) + ) + # away_mode_duration can be either a Literal["always"] + if away_mode_duration == OverkizCommandParam.ALWAYS: + return True + + # Or an int of 0 to 7 days. But it still is a string. + if away_mode_duration.isdecimal() and int(away_mode_duration) > 0: + return True + + return False + + @property + def current_operation(self) -> str | None: + """Return current operation.""" + + # The Away Mode leaves the current operation unchanged + if self.is_boost_mode_on: + return STATE_ELECTRIC + + if self.is_state_eco: + return STATE_ECO + + if self.is_state_performance: + return STATE_PERFORMANCE + + if self.is_state_heat_pump: + return STATE_HEAT_PUMP + + return None + + @property + def is_boost_mode_on(self) -> bool: + """Return true if boost mode is on.""" + + return ( + cast( + int, + self.executor.select_state(OverkizState.CORE_BOOST_MODE_DURATION), + ) + > 0 + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + + if operation_mode == STATE_ECO: + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.MANUAL_ECO_ACTIVE, + refresh_afterwards=False, + ) + # ECO changes the target temperature so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + await self.coordinator.async_refresh() + + elif operation_mode == STATE_PERFORMANCE: + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.AUTO_MODE, + refresh_afterwards=False, + ) + + await self.coordinator.async_refresh() + + elif operation_mode == STATE_HEAT_PUMP: + refresh_target_temp = False + if self.is_state_performance: + # Switching from STATE_PERFORMANCE to STATE_HEAT_PUMP + # changes the target temperature and requires a target temperature refresh + refresh_target_temp = True + + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.MANUAL_ECO_INACTIVE, + refresh_afterwards=False, + ) + + if refresh_target_temp: + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, + refresh_afterwards=False, + ) + + await self.coordinator.async_refresh() + + elif operation_mode == STATE_ELECTRIC: + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + if not self.is_boost_mode_on: + await self.async_turn_boost_mode_on(refresh_afterwards=False) + await self.coordinator.async_refresh() + + async def async_turn_away_mode_on(self, refresh_afterwards: bool = True) -> None: + """Turn away mode on.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.ON, + }, + refresh_afterwards=False, + ) + # Toggling the AWAY mode changes away mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_AWAY_MODE_DURATION, + refresh_afterwards=False, + ) + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_away_mode_off(self, refresh_afterwards: bool = True) -> None: + """Turn away mode off.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + # Toggling the AWAY mode changes away mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_AWAY_MODE_DURATION, + refresh_afterwards=False, + ) + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_boost_mode_on(self, refresh_afterwards: bool = True) -> None: + """Turn boost mode on.""" + + refresh_target_temp = False + if self.is_state_performance: + # Switching from STATE_PERFORMANCE to BOOST requires a target temperature refresh + refresh_target_temp = True + + await self.executor.async_execute_command( + OverkizCommand.SET_BOOST_MODE_DURATION, + MAX_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.ON, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + if refresh_target_temp: + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_boost_mode_off(self, refresh_afterwards: bool = True) -> None: + """Turn boost mode off.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + # Toggling the BOOST mode changes boost mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + if refresh_afterwards: + await self.coordinator.async_refresh() diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index 501ee777fe9..7a6c47796df 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -74,7 +74,7 @@ "status": { "name": "Status", "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "off_timer": "Timer-regulated switch off", "test_fire": "Ignition test", "heatup": "Pellet feed", @@ -83,7 +83,7 @@ "burning": "Operating", "burning_mod": "Operating - Modulating", "unknown": "Unknown", - "cool_fluid": "Stand-by", + "cool_fluid": "[%key:common::state::standby%]", "fire_stop": "Switch off", "clean_fire": "Burn pot cleaning", "cooling": "Cooling in progress", diff --git a/homeassistant/components/pglab/__init__.py b/homeassistant/components/pglab/__init__.py index 8bce7be26e8..a490f476f83 100644 --- a/homeassistant/components/pglab/__init__.py +++ b/homeassistant/components/pglab/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from pypglab.mqtt import ( Client as PyPGLabMqttClient, Sub_State as PyPGLabSubState, - Subcribe_CallBack as PyPGLabSubscribeCallBack, + Subscribe_CallBack as PyPGLabSubscribeCallBack, ) from homeassistant.components import mqtt diff --git a/homeassistant/components/pglab/coordinator.py b/homeassistant/components/pglab/coordinator.py index 53c5dbc3b58..b703f368eb1 100644 --- a/homeassistant/components/pglab/coordinator.py +++ b/homeassistant/components/pglab/coordinator.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE from pypglab.device import Device as PyPGLabDevice -from pypglab.sensor import Sensor as PyPGLabSensors +from pypglab.sensor import StatusSensor as PyPGLabSensors from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -31,7 +31,7 @@ class PGLabSensorsCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Initialize.""" # get a reference of PG Lab device internal sensors state - self._sensors: PyPGLabSensors = pglab_device.sensors + self._sensors: PyPGLabSensors = pglab_device.status_sensor super().__init__( hass, diff --git a/homeassistant/components/pglab/cover.py b/homeassistant/components/pglab/cover.py new file mode 100644 index 00000000000..8385fd95ffa --- /dev/null +++ b/homeassistant/components/pglab/cover.py @@ -0,0 +1,107 @@ +"""PG LAB Electronics Cover.""" + +from __future__ import annotations + +from typing import Any + +from pypglab.device import Device as PyPGLabDevice +from pypglab.shutter import Shutter as PyPGLabShutter + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .discovery import PGLabDiscovery +from .entity import PGLabEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switches for device.""" + + @callback + def async_discover( + pglab_device: PyPGLabDevice, pglab_shutter: PyPGLabShutter + ) -> None: + """Discover and add a PG LAB Cover.""" + pglab_discovery = config_entry.runtime_data + pglab_cover = PGLabCover(pglab_discovery, pglab_device, pglab_shutter) + async_add_entities([pglab_cover]) + + # Register the callback to create the cover entity when discovered. + pglab_discovery = config_entry.runtime_data + await pglab_discovery.register_platform(hass, Platform.COVER, async_discover) + + +class PGLabCover(PGLabEntity, CoverEntity): + """A PGLab Cover.""" + + _attr_translation_key = "shutter" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_shutter: PyPGLabShutter, + ) -> None: + """Initialize the Cover class.""" + + super().__init__( + pglab_discovery, + pglab_device, + pglab_shutter, + ) + + self._attr_unique_id = f"{pglab_device.id}_shutter{pglab_shutter.id}" + self._attr_translation_placeholders = {"shutter_id": pglab_shutter.id} + + self._shutter = pglab_shutter + + self._attr_device_class = CoverDeviceClass.SHUTTER + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._shutter.open() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + await self._shutter.close() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._shutter.stop() + + @property + def is_closed(self) -> bool | None: + """Return if cover is closed.""" + if not self._shutter.state: + return None + return self._shutter.state == PyPGLabShutter.STATE_CLOSED + + @property + def is_closing(self) -> bool | None: + """Return if the cover is closing.""" + if not self._shutter.state: + return None + return self._shutter.state == PyPGLabShutter.STATE_CLOSING + + @property + def is_opening(self) -> bool | None: + """Return if the cover is opening.""" + if not self._shutter.state: + return None + return self._shutter.state == PyPGLabShutter.STATE_OPENING diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index e34f80a2e2d..c83ea4466fa 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -34,12 +34,14 @@ if TYPE_CHECKING: # Supported platforms. PLATFORMS = [ + Platform.COVER, Platform.SENSOR, Platform.SWITCH, ] # Used to create a new component entity. CREATE_NEW_ENTITY = { + Platform.COVER: "pglab_create_new_entity_cover", Platform.SENSOR: "pglab_create_new_entity_sensor", Platform.SWITCH: "pglab_create_new_entity_switch", } @@ -218,7 +220,7 @@ class PGLabDiscovery: configuration_url=f"http://{pglab_device.ip}/", connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, identifiers={(DOMAIN, pglab_device.id)}, - manufacturer=pglab_device.manufactor, + manufacturer=pglab_device.manufacturer, model=pglab_device.type, name=pglab_device.name, sw_version=pglab_device.firmware_version, @@ -250,6 +252,13 @@ class PGLabDiscovery: ) self._discovered[pglab_device.id] = discovery_info + # Create all new cover entities. + for s in pglab_device.shutters: + # the HA entity is not yet created, send a message to create it + async_dispatcher_send( + hass, CREATE_NEW_ENTITY[Platform.COVER], pglab_device, s + ) + # Create all new relay entities. for r in pglab_device.relays: # The HA entity is not yet created, send a message to create it. diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index 59a4e28de89..c0a02f4f835 100644 --- a/homeassistant/components/pglab/entity.py +++ b/homeassistant/components/pglab/entity.py @@ -37,7 +37,7 @@ class PGLabBaseEntity(Entity): sw_version=pglab_device.firmware_version, hw_version=pglab_device.hardware_version, model=pglab_device.type, - manufacturer=pglab_device.manufactor, + manufacturer=pglab_device.manufacturer, configuration_url=f"http://{pglab_device.ip}/", connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, ) diff --git a/homeassistant/components/pglab/manifest.json b/homeassistant/components/pglab/manifest.json index 7f7d596be77..c8dca6c6229 100644 --- a/homeassistant/components/pglab/manifest.json +++ b/homeassistant/components/pglab/manifest.json @@ -9,6 +9,6 @@ "loggers": ["pglab"], "mqtt": ["pglab/discovery/#"], "quality_scale": "bronze", - "requirements": ["pypglab==0.0.3"], + "requirements": ["pypglab==0.0.5"], "single_config_entry": true } diff --git a/homeassistant/components/pglab/strings.json b/homeassistant/components/pglab/strings.json index 4fad408ad98..c6f80d12f09 100644 --- a/homeassistant/components/pglab/strings.json +++ b/homeassistant/components/pglab/strings.json @@ -15,6 +15,11 @@ } }, "entity": { + "cover": { + "shutter": { + "name": "Shutter {shutter_id}" + } + }, "switch": { "relay": { "name": "Relay {relay_id}" diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index bf15292335e..87e3323a30c 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, cast from haphilipsjs import PhilipsTV from haphilipsjs.typing import AmbilightCurrentConfiguration @@ -328,7 +328,7 @@ class PhilipsTVLightEntity(PhilipsJsEntity, LightEntity): """Turn the bulb on.""" brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color) - attr_effect = kwargs.get(ATTR_EFFECT, self.effect) + attr_effect = cast(str, kwargs.get(ATTR_EFFECT, self.effect)) if not self._tv.on: raise HomeAssistantError("TV is not available") diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index 09f28da39a4..251964c15d0 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/picnic", "iot_class": "cloud_polling", "loggers": ["python_picnic_api2"], - "requirements": ["python-picnic-api2==1.2.2"] + "requirements": ["python-picnic-api2==1.2.4"] } diff --git a/homeassistant/components/pitsos/__init__.py b/homeassistant/components/pitsos/__init__.py new file mode 100644 index 00000000000..e49539d8ed2 --- /dev/null +++ b/homeassistant/components/pitsos/__init__.py @@ -0,0 +1 @@ +"""Pitsos virtual integration.""" diff --git a/homeassistant/components/pitsos/manifest.json b/homeassistant/components/pitsos/manifest.json new file mode 100644 index 00000000000..55f5ac7b2fc --- /dev/null +++ b/homeassistant/components/pitsos/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "pitsos", + "name": "Pitsos", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 3c9f35b20a4..48459a81860 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -14,7 +14,6 @@ from plexauth import PlexAuth import requests.exceptions import voluptuous as vol -from homeassistant.components import http from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ( @@ -36,7 +35,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow, http from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 87878980f2d..3f812c1a63b 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.7.2"], + "requirements": ["plugwise==1.7.3"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index d16b38df992..96f5366bb2a 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -85,7 +85,7 @@ "preset_mode": { "state": { "asleep": "Night", - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "home": "[%key:common::state::home%]", "no_frost": "Anti-frost", "vacation": "Vacation" @@ -139,7 +139,7 @@ "select_schedule": { "name": "Thermostat schedule", "state": { - "off": "Off" + "off": "[%key:common::state::off%]" } } }, diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index e446606f191..0f90bd75c9d 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -1,7 +1,5 @@ """Support for Minut Point.""" -import asyncio -from dataclasses import dataclass from http import HTTPStatus import logging @@ -29,26 +27,18 @@ from homeassistant.helpers import ( config_validation as cv, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from . import api -from .const import ( - CONF_WEBHOOK_URL, - DOMAIN, - EVENT_RECEIVED, - POINT_DISCOVERY_NEW, - SCAN_INTERVAL, - SIGNAL_UPDATE_ENTITY, - SIGNAL_WEBHOOK, -) +from .const import CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, SIGNAL_WEBHOOK +from .coordinator import PointDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -type PointConfigEntry = ConfigEntry[PointData] +type PointConfigEntry = ConfigEntry[PointDataUpdateCoordinator] CONFIG_SCHEMA = vol.Schema( { @@ -131,9 +121,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo point_session = PointSession(auth) - client = MinutPointClient(hass, entry, point_session) - hass.async_create_task(client.update()) - entry.runtime_data = PointData(client) + coordinator = PointDataUpdateCoordinator(hass, point_session) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await async_setup_webhook(hass, entry, point_session) await hass.config_entries.async_forward_entry_setups( @@ -176,7 +168,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bo if unload_ok := await hass.config_entries.async_unload_platforms( entry, [*PLATFORMS, Platform.ALARM_CONTROL_PANEL] ): - session: PointSession = entry.runtime_data.client + session = entry.runtime_data.point if CONF_WEBHOOK_ID in entry.data: webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) await session.remove_webhook() @@ -197,87 +189,3 @@ async def handle_webhook( data["webhook_id"] = webhook_id async_dispatcher_send(hass, SIGNAL_WEBHOOK, data, data.get("hook_id")) hass.bus.async_fire(EVENT_RECEIVED, data) - - -class MinutPointClient: - """Get the latest data and update the states.""" - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: PointSession - ) -> None: - """Initialize the Minut data object.""" - self._known_devices: set[str] = set() - self._known_homes: set[str] = set() - self._hass = hass - self._config_entry = config_entry - self._is_available = True - self._client = session - - async_track_time_interval(self._hass, self.update, SCAN_INTERVAL) - - async def update(self, *args): - """Periodically poll the cloud for current state.""" - await self._sync() - - async def _sync(self): - """Update local list of devices.""" - if not await self._client.update(): - self._is_available = False - _LOGGER.warning("Device is unavailable") - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) - return - - self._is_available = True - for home_id in self._client.homes: - if home_id not in self._known_homes: - async_dispatcher_send( - self._hass, - POINT_DISCOVERY_NEW.format(Platform.ALARM_CONTROL_PANEL), - home_id, - ) - self._known_homes.add(home_id) - for device in self._client.devices: - if device.device_id not in self._known_devices: - for platform in PLATFORMS: - async_dispatcher_send( - self._hass, - POINT_DISCOVERY_NEW.format(platform), - device.device_id, - ) - self._known_devices.add(device.device_id) - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) - - def device(self, device_id): - """Return device representation.""" - return self._client.device(device_id) - - def is_available(self, device_id): - """Return device availability.""" - if not self._is_available: - return False - return device_id in self._client.device_ids - - async def remove_webhook(self): - """Remove the session webhook.""" - return await self._client.remove_webhook() - - @property - def homes(self): - """Return known homes.""" - return self._client.homes - - async def async_alarm_disarm(self, home_id): - """Send alarm disarm command.""" - return await self._client.alarm_disarm(home_id) - - async def async_alarm_arm(self, home_id): - """Send alarm arm command.""" - return await self._client.alarm_arm(home_id) - - -@dataclass -class PointData: - """Point Data.""" - - client: MinutPointClient - entry_lock: asyncio.Lock = asyncio.Lock() diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 0f501d2ee09..fa56bf70546 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -2,23 +2,22 @@ from __future__ import annotations -from collections.abc import Callable import logging +from pypoint import PointSession + from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, AlarmControlPanelEntity, AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry 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 AddConfigEntryEntitiesCallback -from . import MinutPointClient -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK +from . import PointConfigEntry +from .const import DOMAIN as POINT_DOMAIN, SIGNAL_WEBHOOK _LOGGER = logging.getLogger(__name__) @@ -32,21 +31,20 @@ EVENT_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's alarm_control_panel based on a config entry.""" + coordinator = config_entry.runtime_data - async def async_discover_home(home_id): + def async_discover_home(home_id: str) -> None: """Discover and add a discovered home.""" - client = config_entry.runtime_data.client - async_add_entities([MinutPointAlarmControl(client, home_id)], True) + async_add_entities([MinutPointAlarmControl(coordinator.point, home_id)]) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(ALARM_CONTROL_PANEL_DOMAIN, POINT_DOMAIN), - async_discover_home, - ) + coordinator.new_home_callback = async_discover_home + + for home_id in coordinator.point.homes: + async_discover_home(home_id) class MinutPointAlarmControl(AlarmControlPanelEntity): @@ -55,12 +53,11 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY _attr_code_arm_required = False - def __init__(self, point_client: MinutPointClient, home_id: str) -> None: + def __init__(self, point: PointSession, home_id: str) -> None: """Initialize the entity.""" - self._client = point_client + self._client = point self._home_id = home_id - self._async_unsub_hook_dispatcher_connect: Callable[[], None] | None = None - self._home = point_client.homes[self._home_id] + self._home = point.homes[self._home_id] self._attr_name = self._home["name"] self._attr_unique_id = f"point.{home_id}" @@ -73,16 +70,10 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" await super().async_added_to_hass() - self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_WEBHOOK, self._webhook_event + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event) ) - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - await super().async_will_remove_from_hass() - if self._async_unsub_hook_dispatcher_connect: - self._async_unsub_hook_dispatcher_connect() - @callback def _webhook_event(self, data, webhook): """Process new event from the webhook.""" @@ -107,12 +98,12 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - status = await self._client.async_alarm_disarm(self._home_id) + status = await self._client.alarm_disarm(self._home_id) if status: self._home["alarm_status"] = "off" async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - status = await self._client.async_alarm_arm(self._home_id) + status = await self._client.alarm_arm(self._home_id) if status: self._home["alarm_status"] = "on" diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index c9338cb63f2..17fe40b9654 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -3,26 +3,27 @@ from __future__ import annotations import logging +from typing import Any from pypoint import EVENTS from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK +from . import PointConfigEntry +from .const import SIGNAL_WEBHOOK +from .coordinator import PointDataUpdateCoordinator from .entity import MinutPointEntity _LOGGER = logging.getLogger(__name__) -DEVICES = { +DEVICES: dict[str, Any] = { "alarm": {"icon": "mdi:alarm-bell"}, "battery": {"device_class": BinarySensorDeviceClass.BATTERY}, "button_press": {"icon": "mdi:gesture-tap-button"}, @@ -42,69 +43,60 @@ DEVICES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's binary sensors based on a config entry.""" - async def async_discover_sensor(device_id): + coordinator = config_entry.runtime_data + + def async_discover_sensor(device_id: str) -> None: """Discover and add a discovered sensor.""" - client = config_entry.runtime_data.client async_add_entities( - ( - MinutPointBinarySensor(client, device_id, device_name) - for device_name in DEVICES - if device_name in EVENTS - ), - True, + MinutPointBinarySensor(coordinator, device_id, device_name) + for device_name in DEVICES + if device_name in EVENTS ) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(BINARY_SENSOR_DOMAIN, POINT_DOMAIN), - async_discover_sensor, + coordinator.new_device_callbacks.append(async_discover_sensor) + + async_add_entities( + MinutPointBinarySensor(coordinator, device_id, device_name) + for device_name in DEVICES + if device_name in EVENTS + for device_id in coordinator.point.device_ids ) class MinutPointBinarySensor(MinutPointEntity, BinarySensorEntity): """The platform class required by Home Assistant.""" - def __init__(self, point_client, device_id, device_name): + def __init__( + self, coordinator: PointDataUpdateCoordinator, device_id: str, key: str + ) -> None: """Initialize the binary sensor.""" - super().__init__( - point_client, - device_id, - DEVICES[device_name].get("device_class", device_name), - ) - self._device_name = device_name - self._async_unsub_hook_dispatcher_connect = None - self._events = EVENTS[device_name] - self._attr_unique_id = f"point.{device_id}-{device_name}" - self._attr_icon = DEVICES[self._device_name].get("icon") + self._attr_device_class = DEVICES[key].get("device_class", key) + super().__init__(coordinator, device_id) + self._device_name = key + self._events = EVENTS[key] + self._attr_unique_id = f"point.{device_id}-{key}" + self._attr_icon = DEVICES[key].get("icon") async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" await super().async_added_to_hass() - self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_WEBHOOK, self._webhook_event + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event) ) - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - await super().async_will_remove_from_hass() - if self._async_unsub_hook_dispatcher_connect: - self._async_unsub_hook_dispatcher_connect() - - async def _update_callback(self): + def _handle_coordinator_update(self) -> None: """Update the value of the sensor.""" - if not self.is_updated: - return if self.device_class == BinarySensorDeviceClass.CONNECTIVITY: # connectivity is the other way around. self._attr_is_on = self._events[0] not in self.device.ongoing_events else: self._attr_is_on = self._events[0] in self.device.ongoing_events - self.async_write_ha_state() + super()._handle_coordinator_update() @callback def _webhook_event(self, data, webhook): diff --git a/homeassistant/components/point/coordinator.py b/homeassistant/components/point/coordinator.py new file mode 100644 index 00000000000..c0cb4e27646 --- /dev/null +++ b/homeassistant/components/point/coordinator.py @@ -0,0 +1,70 @@ +"""Define a data update coordinator for Point.""" + +from collections.abc import Callable +from datetime import datetime +import logging +from typing import Any + +from pypoint import PointSession +from tempora.utc import fromtimestamp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import parse_datetime + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Class to manage fetching Point data from the API.""" + + def __init__(self, hass: HomeAssistant, point: PointSession) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.point = point + self.device_updates: dict[str, datetime] = {} + self._known_devices: set[str] = set() + self._known_homes: set[str] = set() + self.new_home_callback: Callable[[str], None] | None = None + self.new_device_callbacks: list[Callable[[str], None]] = [] + self.data: dict[str, dict[str, Any]] = {} + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + if not await self.point.update(): + raise UpdateFailed("Failed to fetch data from Point") + + if new_homes := set(self.point.homes) - self._known_homes: + _LOGGER.debug("Found new homes: %s", new_homes) + for home_id in new_homes: + if self.new_home_callback: + self.new_home_callback(home_id) + self._known_homes.update(new_homes) + + device_ids = {device.device_id for device in self.point.devices} + if new_devices := device_ids - self._known_devices: + _LOGGER.debug("Found new devices: %s", new_devices) + for device_id in new_devices: + for callback in self.new_device_callbacks: + callback(device_id) + self._known_devices.update(new_devices) + + for device in self.point.devices: + last_updated = parse_datetime(device.last_update) + if ( + not last_updated + or device.device_id not in self.device_updates + or self.device_updates[device.device_id] < last_updated + ): + self.device_updates[device.device_id] = last_updated or fromtimestamp(0) + self.data[device.device_id] = { + k: await device.sensor(k) + for k in ("temperature", "humidity", "sound_pressure") + } + return self.data diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index 5c52e81e6f7..39af7867e97 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -2,31 +2,27 @@ import logging +from pypoint import Device, PointSession + from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity -from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.dt import as_local -from .const import DOMAIN, SIGNAL_UPDATE_ENTITY +from .const import DOMAIN +from .coordinator import PointDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class MinutPointEntity(Entity): +class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]): """Base Entity used by the sensors.""" - _attr_should_poll = False - - def __init__(self, point_client, device_id, device_class) -> None: + def __init__(self, coordinator: PointDataUpdateCoordinator, device_id: str) -> None: """Initialize the entity.""" - self._async_unsub_dispatcher_connect = None - self._client = point_client - self._id = device_id + super().__init__(coordinator) + self.device_id = device_id self._name = self.device.name - self._attr_device_class = device_class - self._updated = utc_from_timestamp(0) - self._attr_unique_id = f"point.{device_id}-{device_class}" device = self.device.device self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, @@ -37,59 +33,32 @@ class MinutPointEntity(Entity): sw_version=device["firmware"]["installed"], via_device=(DOMAIN, device["home"]), ) - if device_class: - self._attr_name = f"{self._name} {device_class.capitalize()}" - - def __str__(self) -> str: - """Return string representation of device.""" - return f"MinutPoint {self.name}" - - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - _LOGGER.debug("Created device %s", self) - self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback - ) - await self._update_callback() - - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - if self._async_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() + if self.device_class: + self._attr_name = f"{self._name} {self.device_class.capitalize()}" async def _update_callback(self): """Update the value of the sensor.""" + @property + def client(self) -> PointSession: + """Return the client object.""" + return self.coordinator.point + @property def available(self) -> bool: """Return true if device is not offline.""" - return self._client.is_available(self.device_id) + return super().available and self.device_id in self.client.device_ids @property - def device(self): + def device(self) -> Device: """Return the representation of the device.""" - return self._client.device(self.device_id) - - @property - def device_id(self): - """Return the id of the device.""" - return self._id + return self.client.device(self.device_id) @property def extra_state_attributes(self): """Return status of device.""" attrs = self.device.device_status - attrs["last_heard_from"] = as_local(self.last_update).strftime( - "%Y-%m-%d %H:%M:%S" - ) + attrs["last_heard_from"] = as_local( + self.coordinator.device_updates[self.device_id] + ).strftime("%Y-%m-%d %H:%M:%S") return attrs - - @property - def is_updated(self): - """Return true if sensor have been updated.""" - return self.last_update > self._updated - - @property - def last_update(self): - """Return the last_update time for the device.""" - return parse_datetime(self.device.last_update) diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index c959d09d606..246536d86ab 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -5,19 +5,17 @@ from __future__ import annotations import logging from homeassistant.components.sensor import ( - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfSoundPressure, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.dt import parse_datetime +from homeassistant.helpers.typing import StateType -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW +from . import PointConfigEntry +from .coordinator import PointDataUpdateCoordinator from .entity import MinutPointEntity _LOGGER = logging.getLogger(__name__) @@ -37,7 +35,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key="sound", + key="sound_pressure", suggested_display_precision=1, device_class=SensorDeviceClass.SOUND_PRESSURE, native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, @@ -47,26 +45,26 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's sensors based on a config entry.""" - async def async_discover_sensor(device_id): + coordinator = config_entry.runtime_data + + def async_discover_sensor(device_id: str) -> None: """Discover and add a discovered sensor.""" - client = config_entry.runtime_data.client async_add_entities( - [ - MinutPointSensor(client, device_id, description) - for description in SENSOR_TYPES - ], - True, + MinutPointSensor(coordinator, device_id, description) + for description in SENSOR_TYPES ) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(SENSOR_DOMAIN, POINT_DOMAIN), - async_discover_sensor, + coordinator.new_device_callbacks.append(async_discover_sensor) + + async_add_entities( + MinutPointSensor(coordinator, device_id, description) + for device_id in coordinator.data + for description in SENSOR_TYPES ) @@ -74,16 +72,17 @@ class MinutPointSensor(MinutPointEntity, SensorEntity): """The platform class required by Home Assistant.""" def __init__( - self, point_client, device_id, description: SensorEntityDescription + self, + coordinator: PointDataUpdateCoordinator, + device_id: str, + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(point_client, device_id, description.device_class) self.entity_description = description + super().__init__(coordinator, device_id) + self._attr_unique_id = f"point.{device_id}-{description.key}" - async def _update_callback(self): - """Update the value of the sensor.""" - _LOGGER.debug("Update sensor value for %s", self) - if self.is_updated: - self._attr_native_value = await self.device.sensor(self.device_class) - self._updated = parse_datetime(self.device.last_update) - self.async_write_ha_state() + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.coordinator.data[self.device_id].get(self.entity_description.key) diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 810fce41e05..ceafd8dc4f7 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.1"] + "requirements": ["bluetooth-data-tools==1.27.0"] } diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json index c35775a4843..845a5d92bae 100644 --- a/homeassistant/components/private_ble_device/strings.json +++ b/homeassistant/components/private_ble_device/strings.json @@ -14,7 +14,7 @@ "irk_not_valid": "The key does not look like a valid IRK." }, "abort": { - "bluetooth_not_available": "At least one Bluetooth adapter or remote bluetooth proxy must be configured to track Private BLE Devices." + "bluetooth_not_available": "At least one Bluetooth adapter or remote Bluetooth proxy must be configured to track Private BLE Devices." } }, "entity": { diff --git a/homeassistant/components/profilo/__init__.py b/homeassistant/components/profilo/__init__.py new file mode 100644 index 00000000000..5f727b1bc8b --- /dev/null +++ b/homeassistant/components/profilo/__init__.py @@ -0,0 +1 @@ +"""Profilo virtual integration.""" diff --git a/homeassistant/components/profilo/manifest.json b/homeassistant/components/profilo/manifest.json new file mode 100644 index 00000000000..c5671d5be3f --- /dev/null +++ b/homeassistant/components/profilo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "profilo", + "name": "Profilo", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 2e5ea221dca..8818eff2d81 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -1,5 +1,6 @@ """Config flow for ProgettiHWSW Automation integration.""" +import logging from typing import TYPE_CHECKING, Any from ProgettiHWSW.ProgettiHWSWAPI import ProgettiHWSWAPI @@ -11,6 +12,8 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( {vol.Required("host"): str, vol.Required("port", default=80): int} ) @@ -86,7 +89,8 @@ class ProgettiHWSWConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: user_input.update(info) diff --git a/homeassistant/components/prosegur/strings.json b/homeassistant/components/prosegur/strings.json index 9b9ac45fc85..e5176e96090 100644 --- a/homeassistant/components/prosegur/strings.json +++ b/homeassistant/components/prosegur/strings.json @@ -5,7 +5,7 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" } }, "choose_contract": { diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 0db6ea28652..11fa530f47b 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -6,7 +6,6 @@ from datetime import timedelta from typing import Any from proxmoxer import AuthenticationError, ProxmoxAPI -from proxmoxer.core import ResourceException import requests.exceptions from requests.exceptions import ConnectTimeout, SSLError import voluptuous as vol @@ -25,6 +24,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .common import ProxmoxClient, call_api_container_vm, parse_api_container_vm from .const import ( _LOGGER, CONF_CONTAINERS, @@ -219,80 +219,3 @@ def create_coordinator_container_vm( update_method=async_update_data, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - - -def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: - """Get the container or vm api data and return it formatted in a dictionary. - - It is implemented in this way to allow for more data to be added for sensors - in the future. - """ - - return {"status": status["status"], "name": status["name"]} - - -def call_api_container_vm( - proxmox: ProxmoxAPI, - node_name: str, - vm_id: int, - machine_type: int, -) -> dict[str, Any] | None: - """Make proper api calls.""" - status = None - - try: - if machine_type == TYPE_VM: - status = proxmox.nodes(node_name).qemu(vm_id).status.current.get() - elif machine_type == TYPE_CONTAINER: - status = proxmox.nodes(node_name).lxc(vm_id).status.current.get() - except (ResourceException, requests.exceptions.ConnectionError): - return None - - return status - - -class ProxmoxClient: - """A wrapper for the proxmoxer ProxmoxAPI client.""" - - _proxmox: ProxmoxAPI - - def __init__( - self, - host: str, - port: int, - user: str, - realm: str, - password: str, - verify_ssl: bool, - ) -> None: - """Initialize the ProxmoxClient.""" - - self._host = host - self._port = port - self._user = user - self._realm = realm - self._password = password - self._verify_ssl = verify_ssl - - def build_client(self) -> None: - """Construct the ProxmoxAPI client. - - Allows inserting the realm within the `user` value. - """ - - if "@" in self._user: - user_id = self._user - else: - user_id = f"{self._user}@{self._realm}" - - self._proxmox = ProxmoxAPI( - self._host, - port=self._port, - user=user_id, - password=self._password, - verify_ssl=self._verify_ssl, - ) - - def get_api_client(self) -> ProxmoxAPI: - """Return the ProxmoxAPI client.""" - return self._proxmox diff --git a/homeassistant/components/proxmoxve/common.py b/homeassistant/components/proxmoxve/common.py new file mode 100644 index 00000000000..4173377377c --- /dev/null +++ b/homeassistant/components/proxmoxve/common.py @@ -0,0 +1,88 @@ +"""Commons for Proxmox VE integration.""" + +from __future__ import annotations + +from typing import Any + +from proxmoxer import ProxmoxAPI +from proxmoxer.core import ResourceException +import requests.exceptions + +from .const import TYPE_CONTAINER, TYPE_VM + + +class ProxmoxClient: + """A wrapper for the proxmoxer ProxmoxAPI client.""" + + _proxmox: ProxmoxAPI + + def __init__( + self, + host: str, + port: int, + user: str, + realm: str, + password: str, + verify_ssl: bool, + ) -> None: + """Initialize the ProxmoxClient.""" + + self._host = host + self._port = port + self._user = user + self._realm = realm + self._password = password + self._verify_ssl = verify_ssl + + def build_client(self) -> None: + """Construct the ProxmoxAPI client. + + Allows inserting the realm within the `user` value. + """ + + if "@" in self._user: + user_id = self._user + else: + user_id = f"{self._user}@{self._realm}" + + self._proxmox = ProxmoxAPI( + self._host, + port=self._port, + user=user_id, + password=self._password, + verify_ssl=self._verify_ssl, + ) + + def get_api_client(self) -> ProxmoxAPI: + """Return the ProxmoxAPI client.""" + return self._proxmox + + +def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: + """Get the container or vm api data and return it formatted in a dictionary. + + It is implemented in this way to allow for more data to be added for sensors + in the future. + """ + + return {"status": status["status"], "name": status["name"]} + + +def call_api_container_vm( + proxmox: ProxmoxAPI, + node_name: str, + vm_id: int, + machine_type: int, +) -> dict[str, Any] | None: + """Make proper api calls.""" + status = None + + try: + if machine_type == TYPE_VM: + status = proxmox.nodes(node_name).qemu(vm_id).status.current.get() + elif machine_type == TYPE_CONTAINER: + status = proxmox.nodes(node_name).lxc(vm_id).status.current.get() + except (ResourceException, requests.exceptions.ConnectionError): + return None + + return status diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py index f6e909f13d1..47fa9454deb 100644 --- a/homeassistant/components/proxy/camera.py +++ b/homeassistant/components/proxy/camera.py @@ -104,6 +104,15 @@ def _resize_image(image, opts): new_width = opts.max_width (old_width, old_height) = img.size old_size = len(image) + + # If no max_width specified, only apply quality changes if requested + if new_width is None: + if opts.quality is None: + return image + imgbuf = io.BytesIO() + img.save(imgbuf, "JPEG", optimize=True, quality=quality) + return imgbuf.getvalue() + if old_width <= new_width: if opts.quality is None: _LOGGER.debug("Image is smaller-than/equal-to requested width") diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index 7c6f0bbf2dd..036bd2c9c6e 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -36,7 +36,7 @@ "printing": "Printing", "paused": "[%key:common::state::paused%]", "finished": "Finished", - "stopped": "Stopped", + "stopped": "[%key:common::state::stopped%]", "error": "Error", "attention": "Attention", "ready": "Ready" @@ -85,7 +85,7 @@ "name": "Z-Height" }, "nozzle_diameter": { - "name": "Nozzle Diameter" + "name": "Nozzle diameter" } }, "button": { diff --git a/homeassistant/components/pterodactyl/__init__.py b/homeassistant/components/pterodactyl/__init__.py new file mode 100644 index 00000000000..c0e23b271d1 --- /dev/null +++ b/homeassistant/components/pterodactyl/__init__.py @@ -0,0 +1,27 @@ +"""The Pterodactyl integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator + +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: PterodactylConfigEntry) -> bool: + """Set up Pterodactyl from a config entry.""" + coordinator = PterodactylCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: PterodactylConfigEntry +) -> bool: + """Unload a Pterodactyl config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py new file mode 100644 index 00000000000..40ede9de103 --- /dev/null +++ b/homeassistant/components/pterodactyl/api.py @@ -0,0 +1,150 @@ +"""API module of the Pterodactyl integration.""" + +from dataclasses import dataclass +from enum import StrEnum +import logging + +from pydactyl import PterodactylClient +from pydactyl.exceptions import BadRequestError, PterodactylApiError +from requests.exceptions import ConnectionError, HTTPError + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +class PterodactylAuthorizationError(Exception): + """Raised when access to server is unauthorized.""" + + +class PterodactylConnectionError(Exception): + """Raised when no data can be fechted from the server.""" + + +@dataclass +class PterodactylData: + """Data for the Pterodactyl server.""" + + name: str + uuid: str + identifier: str + state: str + cpu_utilization: float + cpu_limit: int + disk_usage: int + disk_limit: int + memory_usage: int + memory_limit: int + network_inbound: int + network_outbound: int + uptime: int + + +class PterodactylCommand(StrEnum): + """Command enum for the Pterodactyl server.""" + + START_SERVER = "start" + STOP_SERVER = "stop" + RESTART_SERVER = "restart" + FORCE_STOP_SERVER = "kill" + + +class PterodactylAPI: + """Wrapper for Pterodactyl's API.""" + + pterodactyl: PterodactylClient | None + identifiers: list[str] + + def __init__(self, hass: HomeAssistant, host: str, api_key: str) -> None: + """Initialize the Pterodactyl API.""" + self.hass = hass + self.host = host + self.api_key = api_key + self.pterodactyl = None + self.identifiers = [] + + async def async_init(self): + """Initialize the Pterodactyl API.""" + self.pterodactyl = PterodactylClient(self.host, self.api_key) + + try: + paginated_response = await self.hass.async_add_executor_job( + self.pterodactyl.client.servers.list_servers + ) + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + + raise PterodactylConnectionError(error) from error + else: + game_servers = paginated_response.collect() + for game_server in game_servers: + self.identifiers.append(game_server["attributes"]["identifier"]) + + _LOGGER.debug("Identifiers of Pterodactyl servers: %s", self.identifiers) + + def get_server_data(self, identifier: str) -> tuple[dict, dict]: + """Get all data from the Pterodactyl server.""" + server = self.pterodactyl.client.servers.get_server(identifier) # type: ignore[union-attr] + utilization = self.pterodactyl.client.servers.get_server_utilization( # type: ignore[union-attr] + identifier + ) + + return server, utilization + + async def async_get_data(self) -> dict[str, PterodactylData]: + """Update the data from all Pterodactyl servers.""" + data = {} + + for identifier in self.identifiers: + try: + server, utilization = await self.hass.async_add_executor_job( + self.get_server_data, identifier + ) + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + + raise PterodactylConnectionError(error) from error + else: + data[identifier] = PterodactylData( + name=server["name"], + uuid=server["uuid"], + identifier=identifier, + state=utilization["current_state"], + cpu_utilization=utilization["resources"]["cpu_absolute"], + cpu_limit=server["limits"]["cpu"], + memory_usage=utilization["resources"]["memory_bytes"], + memory_limit=server["limits"]["memory"], + disk_usage=utilization["resources"]["disk_bytes"], + disk_limit=server["limits"]["disk"], + network_inbound=utilization["resources"]["network_rx_bytes"], + network_outbound=utilization["resources"]["network_tx_bytes"], + uptime=utilization["resources"]["uptime"], + ) + + _LOGGER.debug("%s", data[identifier]) + + return data + + async def async_send_command( + self, identifier: str, command: PterodactylCommand + ) -> None: + """Send a command to the Pterodactyl server.""" + try: + await self.hass.async_add_executor_job( + self.pterodactyl.client.servers.send_power_action, # type: ignore[union-attr] + identifier, + command, + ) + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + + raise PterodactylConnectionError(error) from error diff --git a/homeassistant/components/pterodactyl/binary_sensor.py b/homeassistant/components/pterodactyl/binary_sensor.py new file mode 100644 index 00000000000..e3615c47499 --- /dev/null +++ b/homeassistant/components/pterodactyl/binary_sensor.py @@ -0,0 +1,64 @@ +"""Binary sensor platform of the Pterodactyl integration.""" + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator +from .entity import PterodactylEntity + +KEY_STATUS = "status" + + +BINARY_SENSOR_DESCRIPTIONS = [ + BinarySensorEntityDescription( + key=KEY_STATUS, + translation_key=KEY_STATUS, + device_class=BinarySensorDeviceClass.RUNNING, + ), +] + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl binary sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + PterodactylBinarySensorEntity( + coordinator, identifier, description, config_entry + ) + for identifier in coordinator.api.identifiers + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class PterodactylBinarySensorEntity(PterodactylEntity, BinarySensorEntity): + """Representation of a Pterodactyl binary sensor base entity.""" + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: BinarySensorEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize binary sensor base entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" + + @property + def is_on(self) -> bool: + """Return binary sensor state.""" + return self.game_server_data.state == "running" diff --git a/homeassistant/components/pterodactyl/button.py b/homeassistant/components/pterodactyl/button.py new file mode 100644 index 00000000000..44d3a6d0a82 --- /dev/null +++ b/homeassistant/components/pterodactyl/button.py @@ -0,0 +1,106 @@ +"""Button platform for the Pterodactyl integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .api import ( + PterodactylAuthorizationError, + PterodactylCommand, + PterodactylConnectionError, +) +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator +from .entity import PterodactylEntity + +KEY_START_SERVER = "start_server" +KEY_STOP_SERVER = "stop_server" +KEY_RESTART_SERVER = "restart_server" +KEY_FORCE_STOP_SERVER = "force_stop_server" + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PterodactylButtonEntityDescription(ButtonEntityDescription): + """Class describing Pterodactyl button entities.""" + + command: PterodactylCommand + + +BUTTON_DESCRIPTIONS = [ + PterodactylButtonEntityDescription( + key=KEY_START_SERVER, + translation_key=KEY_START_SERVER, + command=PterodactylCommand.START_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_STOP_SERVER, + translation_key=KEY_STOP_SERVER, + command=PterodactylCommand.STOP_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_RESTART_SERVER, + translation_key=KEY_RESTART_SERVER, + command=PterodactylCommand.RESTART_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_FORCE_STOP_SERVER, + translation_key=KEY_FORCE_STOP_SERVER, + command=PterodactylCommand.FORCE_STOP_SERVER, + entity_registry_enabled_default=False, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl button platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + PterodactylButtonEntity(coordinator, identifier, description, config_entry) + for identifier in coordinator.api.identifiers + for description in BUTTON_DESCRIPTIONS + ) + + +class PterodactylButtonEntity(PterodactylEntity, ButtonEntity): + """Representation of a Pterodactyl button entity.""" + + entity_description: PterodactylButtonEntityDescription + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: PterodactylButtonEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.coordinator.api.async_send_command( + self.identifier, self.entity_description.command + ) + except PterodactylConnectionError as err: + raise HomeAssistantError( + f"Failed to send action '{self.entity_description.key}': Connection error" + ) from err + except PterodactylAuthorizationError as err: + raise HomeAssistantError( + f"Failed to send action '{self.entity_description.key}': Unauthorized" + ) from err diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py new file mode 100644 index 00000000000..db03c89f95e --- /dev/null +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -0,0 +1,110 @@ +"""Config flow for the Pterodactyl integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_URL + +from .api import ( + PterodactylAPI, + PterodactylAuthorizationError, + PterodactylConnectionError, +) +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_URL = "http://localhost:8080" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): str, + vol.Required(CONF_API_KEY): str, + } +) + +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } +) + + +class PterodactylConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Pterodactyl.""" + + VERSION = 1 + + async def async_validate_connection(self, url: str, api_key: str) -> dict[str, str]: + """Validate the connection to the Pterodactyl server.""" + errors: dict[str, str] = {} + api = PterodactylAPI(self.hass, url, api_key) + + try: + await api.async_init() + except PterodactylAuthorizationError: + errors["base"] = "invalid_auth" + except PterodactylConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception occurred during config flow") + errors["base"] = "unknown" + + return errors + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + url = URL(user_input[CONF_URL]).human_repr() + api_key = user_input[CONF_API_KEY] + + self._async_abort_entries_match({CONF_URL: url}) + errors = await self.async_validate_connection(url, api_key) + + if not errors: + return self.async_create_entry(title=url, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform re-authentication on an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that re-authentication is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + url = reauth_entry.data[CONF_URL] + api_key = user_input[CONF_API_KEY] + + errors = await self.async_validate_connection(url, api_key) + + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/pterodactyl/const.py b/homeassistant/components/pterodactyl/const.py new file mode 100644 index 00000000000..8cf4d0c3963 --- /dev/null +++ b/homeassistant/components/pterodactyl/const.py @@ -0,0 +1,3 @@ +"""Constants for the Pterodactyl integration.""" + +DOMAIN = "pterodactyl" diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py new file mode 100644 index 00000000000..6d644e96e4c --- /dev/null +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -0,0 +1,71 @@ +"""Data update coordinator of the Pterodactyl integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import ( + PterodactylAPI, + PterodactylAuthorizationError, + PterodactylConnectionError, + PterodactylData, +) + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + +type PterodactylConfigEntry = ConfigEntry[PterodactylCoordinator] + + +class PterodactylCoordinator(DataUpdateCoordinator[dict[str, PterodactylData]]): + """Pterodactyl data update coordinator.""" + + config_entry: PterodactylConfigEntry + api: PterodactylAPI + + def __init__( + self, + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize coordinator instance.""" + + super().__init__( + hass=hass, + name=config_entry.data[CONF_URL], + config_entry=config_entry, + logger=_LOGGER, + update_interval=SCAN_INTERVAL, + ) + + async def _async_setup(self) -> None: + """Set up the Pterodactyl data coordinator.""" + self.api = PterodactylAPI( + hass=self.hass, + host=self.config_entry.data[CONF_URL], + api_key=self.config_entry.data[CONF_API_KEY], + ) + + try: + await self.api.async_init() + except PterodactylConnectionError as error: + raise UpdateFailed(error) from error + except PterodactylAuthorizationError as error: + raise ConfigEntryAuthFailed(error) from error + + async def _async_update_data(self) -> dict[str, PterodactylData]: + """Get updated data from the Pterodactyl server.""" + try: + return await self.api.async_get_data() + except PterodactylConnectionError as error: + raise UpdateFailed(error) from error + except PterodactylAuthorizationError as error: + raise ConfigEntryAuthFailed(error) from error diff --git a/homeassistant/components/pterodactyl/entity.py b/homeassistant/components/pterodactyl/entity.py new file mode 100644 index 00000000000..49fd65af476 --- /dev/null +++ b/homeassistant/components/pterodactyl/entity.py @@ -0,0 +1,47 @@ +"""Base entity for the Pterodactyl integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .api import PterodactylData +from .const import DOMAIN +from .coordinator import PterodactylCoordinator + +MANUFACTURER = "Pterodactyl" + + +class PterodactylEntity(CoordinatorEntity[PterodactylCoordinator]): + """Representation of a Pterodactyl base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + config_entry: ConfigEntry, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + + self.identifier = identifier + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer=MANUFACTURER, + name=self.game_server_data.name, + model=self.game_server_data.name, + model_id=self.game_server_data.uuid, + configuration_url=f"{config_entry.data[CONF_URL]}/server/{identifier}", + ) + + @property + def available(self) -> bool: + """Return binary sensor availability.""" + return super().available and self.identifier in self.coordinator.data + + @property + def game_server_data(self) -> PterodactylData: + """Return game server data.""" + return self.coordinator.data[self.identifier] diff --git a/homeassistant/components/pterodactyl/icons.json b/homeassistant/components/pterodactyl/icons.json new file mode 100644 index 00000000000..265a8dcadda --- /dev/null +++ b/homeassistant/components/pterodactyl/icons.json @@ -0,0 +1,47 @@ +{ + "entity": { + "button": { + "start_server": { + "default": "mdi:play" + }, + "stop_server": { + "default": "mdi:stop" + }, + "restart_server": { + "default": "mdi:refresh" + }, + "force_stop_server": { + "default": "mdi:flash-alert" + } + }, + "sensor": { + "cpu_utilization": { + "default": "mdi:cpu-64-bit" + }, + "cpu_limit": { + "default": "mdi:cpu-64-bit" + }, + "memory_usage": { + "default": "mdi:memory" + }, + "memory_limit": { + "default": "mdi:memory" + }, + "disk_usage": { + "default": "mdi:harddisk" + }, + "disk_limit": { + "default": "mdi:harddisk" + }, + "network_inbound": { + "default": "mdi:download" + }, + "network_outbound": { + "default": "mdi:upload" + }, + "uptime": { + "default": "mdi:timer" + } + } + } +} diff --git a/homeassistant/components/pterodactyl/manifest.json b/homeassistant/components/pterodactyl/manifest.json new file mode 100644 index 00000000000..8ffa21dd186 --- /dev/null +++ b/homeassistant/components/pterodactyl/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "pterodactyl", + "name": "Pterodactyl", + "codeowners": ["@elmurato"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/pterodactyl", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["py-dactyl==2.0.4"] +} diff --git a/homeassistant/components/pterodactyl/quality_scale.yaml b/homeassistant/components/pterodactyl/quality_scale.yaml new file mode 100644 index 00000000000..80ebb3fc7e3 --- /dev/null +++ b/homeassistant/components/pterodactyl/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration doesn't provide any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration doesn't provide any service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + comment: Handled by coordinator. + entity-unique-id: + status: done + comment: Using confid entry ID as the dependency pydactyl doesn't provide a unique information. + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: done + comment: | + Raising ConfigEntryNotReady, if the initialization isn't successful. + unique-config-entry: + status: done + comment: | + As there is no unique information available from the dependency pydactyl, + the server host is used to identify that the same service is already configured. + + # Silver + action-exceptions: + status: exempt + comment: Integration doesn't provide any service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration doesn't support any configuration parameters. + docs-installation-parameters: todo + entity-unavailable: + status: done + comment: Handled by coordinator. + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by coordinator. + parallel-updates: done + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: No discovery possible. + discovery-update-info: + status: exempt + comment: | + No discovery possible. Users can use the (local or public) hostname instead of an IP address, + if static IP addresses cannot be configured. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair use-cases for this integration. + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: Integration isn't making any HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/pterodactyl/sensor.py b/homeassistant/components/pterodactyl/sensor.py new file mode 100644 index 00000000000..646b429cd08 --- /dev/null +++ b/homeassistant/components/pterodactyl/sensor.py @@ -0,0 +1,183 @@ +"""Sensor platform of the Pterodactyl integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator, PterodactylData +from .entity import PterodactylEntity + +KEY_CPU_UTILIZATION = "cpu_utilization" +KEY_CPU_LIMIT = "cpu_limit" +KEY_MEMORY_USAGE = "memory_usage" +KEY_MEMORY_LIMIT = "memory_limit" +KEY_DISK_USAGE = "disk_usage" +KEY_DISK_LIMIT = "disk_limit" +KEY_NETWORK_INBOUND = "network_inbound" +KEY_NETWORK_OUTBOUND = "network_outbound" +KEY_UPTIME = "uptime" + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PterodactylSensorEntityDescription(SensorEntityDescription): + """Class describing Pterodactyl sensor entities.""" + + value_fn: Callable[[PterodactylData], StateType | datetime] + + +SENSOR_DESCRIPTIONS = [ + PterodactylSensorEntityDescription( + key=KEY_CPU_UTILIZATION, + translation_key=KEY_CPU_UTILIZATION, + value_fn=lambda data: data.cpu_utilization, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + ), + PterodactylSensorEntityDescription( + key=KEY_CPU_LIMIT, + translation_key=KEY_CPU_LIMIT, + value_fn=lambda data: data.cpu_limit, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_MEMORY_USAGE, + translation_key=KEY_MEMORY_USAGE, + value_fn=lambda data: data.memory_usage, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + ), + PterodactylSensorEntityDescription( + key=KEY_MEMORY_LIMIT, + translation_key=KEY_MEMORY_LIMIT, + value_fn=lambda data: data.memory_limit, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_DISK_USAGE, + translation_key=KEY_DISK_USAGE, + value_fn=lambda data: data.disk_usage, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + ), + PterodactylSensorEntityDescription( + key=KEY_DISK_LIMIT, + translation_key=KEY_DISK_LIMIT, + value_fn=lambda data: data.disk_limit, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_NETWORK_INBOUND, + translation_key=KEY_NETWORK_INBOUND, + value_fn=lambda data: data.network_inbound, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_NETWORK_OUTBOUND, + translation_key=KEY_NETWORK_OUTBOUND, + value_fn=lambda data: data.network_outbound, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_UPTIME, + translation_key=KEY_UPTIME, + value_fn=( + lambda data: dt_util.utcnow() - timedelta(milliseconds=data.uptime) + if data.uptime > 0 + else None + ), + device_class=SensorDeviceClass.TIMESTAMP, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + PterodactylSensorEntity(coordinator, identifier, description, config_entry) + for identifier in coordinator.api.identifiers + for description in SENSOR_DESCRIPTIONS + ) + + +class PterodactylSensorEntity(PterodactylEntity, SensorEntity): + """Representation of a Pterodactyl sensor base entity.""" + + entity_description: PterodactylSensorEntityDescription + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: PterodactylSensorEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize sensor base entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" + + @property + def native_value(self) -> StateType | datetime: + """Return native value of sensor.""" + return self.entity_description.value_fn(self.game_server_data) diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json new file mode 100644 index 00000000000..3d01700f189 --- /dev/null +++ b/homeassistant/components/pterodactyl/strings.json @@ -0,0 +1,85 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "The URL of your Pterodactyl server, including the protocol (http:// or https://) and optionally the port number.", + "api_key": "The account API key for accessing your Pterodactyl server." + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please update your account API key.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::pterodactyl::config::step::user::data_description::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + }, + "button": { + "start_server": { + "name": "Start server" + }, + "stop_server": { + "name": "Stop server" + }, + "restart_server": { + "name": "Restart server" + }, + "force_stop_server": { + "name": "Force stop server" + } + }, + "sensor": { + "cpu_utilization": { + "name": "CPU utilization" + }, + "cpu_limit": { + "name": "CPU limit" + }, + "memory_usage": { + "name": "Memory usage" + }, + "memory_limit": { + "name": "Memory limit" + }, + "disk_usage": { + "name": "Disk usage" + }, + "disk_limit": { + "name": "Disk limit" + }, + "network_inbound": { + "name": "Network inbound" + }, + "network_outbound": { + "name": "Network outbound" + }, + "uptime": { + "name": "Uptime" + } + } + } +} diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index 9dbdad53bcb..dee5f9cda6e 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pvoutput", "integration_type": "device", "iot_class": "cloud_polling", - "requirements": ["pvo==2.2.0"] + "requirements": ["pvo==2.2.1"] } diff --git a/homeassistant/components/pvoutput/strings.json b/homeassistant/components/pvoutput/strings.json index 06d98971053..651bb55a2b4 100644 --- a/homeassistant/components/pvoutput/strings.json +++ b/homeassistant/components/pvoutput/strings.json @@ -27,19 +27,19 @@ "entity": { "sensor": { "energy_consumption": { - "name": "Energy consumed" + "name": "Energy consumption" }, "energy_generation": { - "name": "Energy generated" + "name": "Energy generation" }, "efficiency": { "name": "Efficiency" }, "power_consumption": { - "name": "Power consumed" + "name": "Power consumption" }, "power_generation": { - "name": "Power generated" + "name": "Power generation" } } } diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index c57dfa7720d..7bb2b870520 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -31,6 +31,7 @@ class PyLoadData: download: bool reconnect: bool captcha: bool | None = None + proxy: bool | None = None free_space: int diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index ee613eb96c2..ef2f45bbc28 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -53,9 +53,9 @@ "connection_status": { "name": "Connection status", "state": { - "connected": "Connected", + "connected": "[%key:common::state::connected%]", "firewalled": "Firewalled", - "disconnected": "Disconnected" + "disconnected": "[%key:common::state::disconnected%]" } }, "active_torrents": { diff --git a/homeassistant/components/qbus/climate.py b/homeassistant/components/qbus/climate.py new file mode 100644 index 00000000000..57d97c046b7 --- /dev/null +++ b/homeassistant/components/qbus/climate.py @@ -0,0 +1,172 @@ +"""Support for Qbus thermostat.""" + +import logging +from typing import Any + +from qbusmqttapi.const import KEY_PROPERTIES_REGIME, KEY_PROPERTIES_SET_TEMPERATURE +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttThermoState, StateType + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.mqtt import ReceiveMessage, client as mqtt +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, add_new_outputs + +PARALLEL_UPDATES = 0 + +STATE_REQUEST_DELAY = 2 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up climate entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _check_outputs() -> None: + add_new_outputs( + coordinator, + added_outputs, + lambda output: output.type == "thermo", + QbusClimate, + async_add_entities, + ) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusClimate(QbusEntity, ClimateEntity): + """Representation of a Qbus climate entity.""" + + _attr_hvac_modes = [HVACMode.HEAT] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize climate entity.""" + + super().__init__(mqtt_output) + + self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVACMode.HEAT + + set_temp: dict[str, Any] = mqtt_output.properties.get( + KEY_PROPERTIES_SET_TEMPERATURE, {} + ) + current_regime: dict[str, Any] = mqtt_output.properties.get( + KEY_PROPERTIES_REGIME, {} + ) + + self._attr_min_temp: float = set_temp.get("min", 0) + self._attr_max_temp: float = set_temp.get("max", 35) + self._attr_target_temperature_step: float = set_temp.get("step", 0.5) + self._attr_preset_modes: list[str] = current_regime.get("enumValues", []) + self._attr_preset_mode: str = ( + self._attr_preset_modes[0] if len(self._attr_preset_modes) > 0 else "" + ) + + self._request_state_debouncer: Debouncer | None = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self._request_state_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=STATE_REQUEST_DELAY, + immediate=False, + function=self._async_request_state, + ) + await super().async_added_to_hass() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + + if preset_mode not in self._attr_preset_modes: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_preset", + translation_placeholders={ + "preset": preset_mode, + "options": ", ".join(self._attr_preset_modes), + }, + ) + + state = QbusMqttThermoState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_regime(preset_mode) + + await self._async_publish_output_state(state) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + + if temperature is not None and isinstance(temperature, float): + state = QbusMqttThermoState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_set_temperature(temperature) + + await self._async_publish_output_state(state) + + async def _state_received(self, msg: ReceiveMessage) -> None: + state = self._message_factory.parse_output_state( + QbusMqttThermoState, msg.payload + ) + + if state is None: + return + + if preset_mode := state.read_regime(): + self._attr_preset_mode = preset_mode + + if current_temperature := state.read_current_temperature(): + self._attr_current_temperature = current_temperature + + if target_temperature := state.read_set_temperature(): + self._attr_target_temperature = target_temperature + + self._set_hvac_action() + + # When the state type is "event", the payload only contains the changed + # property. Request the state to get the full payload. However, changing + # temperature step by step could cause a flood of state requests, so we're + # holding off a few seconds before requesting the full state. + if state.type == StateType.EVENT: + assert self._request_state_debouncer is not None + await self._request_state_debouncer.async_call() + + self.async_schedule_update_ha_state() + + def _set_hvac_action(self) -> None: + if self.target_temperature is None or self.current_temperature is None: + self._attr_hvac_action = HVACAction.IDLE + return + + self._attr_hvac_action = ( + HVACAction.HEATING + if self.target_temperature > self.current_temperature + else HVACAction.IDLE + ) + + async def _async_request_state(self) -> None: + request = self._message_factory.create_state_request([self._mqtt_output.id]) + await mqtt.async_publish(self.hass, request.topic, request.payload) diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index b9e42f13766..767a41f48cc 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -6,6 +6,7 @@ from homeassistant.const import Platform DOMAIN: Final = "qbus" PLATFORMS: list[Platform] = [ + Platform.CLIMATE, Platform.LIGHT, Platform.SWITCH, ] diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 5ec76f5e807..3d2c763b8e3 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -51,7 +51,7 @@ class QbusLight(QbusEntity, LightEntity): super().__init__(mqtt_output) - self._set_state() + self._set_state(0) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -74,7 +74,6 @@ class QbusLight(QbusEntity, LightEntity): state.write_percentage(percentage) await self._async_publish_output_state(state) - self._set_state(percentage=percentage, on=on) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" @@ -82,7 +81,6 @@ class QbusLight(QbusEntity, LightEntity): state.write_on_off(on=False) await self._async_publish_output_state(state) - self._set_state(on=False) async def _state_received(self, msg: ReceiveMessage) -> None: output = self._message_factory.parse_output_state( @@ -91,20 +89,9 @@ class QbusLight(QbusEntity, LightEntity): if output is not None: percentage = round(output.read_percentage()) - self._set_state(percentage=percentage) + self._set_state(percentage) self.async_schedule_update_ha_state() - def _set_state( - self, *, percentage: int | None = None, on: bool | None = None - ) -> None: - if percentage is None: - # When turning on without brightness, we don't know the desired - # brightness. It will be set during _state_received(). - if on is True: - self._attr_is_on = True - else: - self._attr_is_on = False - self._attr_brightness = 0 - else: - self._attr_is_on = percentage > 0 - self._attr_brightness = value_to_brightness((1, 100), percentage) + def _set_state(self, percentage: int = 0) -> None: + self._attr_is_on = percentage > 0 + self._attr_brightness = value_to_brightness((1, 100), percentage) diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json index e6df18c393c..f308c5b3519 100644 --- a/homeassistant/components/qbus/strings.json +++ b/homeassistant/components/qbus/strings.json @@ -15,5 +15,10 @@ "error": { "no_controller": "No controllers were found" } + }, + "exceptions": { + "invalid_preset": { + "message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}." + } } } diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py index 002ad43e904..e1feccf4450 100644 --- a/homeassistant/components/qbus/switch.py +++ b/homeassistant/components/qbus/switch.py @@ -57,7 +57,6 @@ class QbusSwitch(QbusEntity, SwitchEntity): state.write_value(True) await self._async_publish_output_state(state) - self._attr_is_on = True async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" @@ -65,7 +64,6 @@ class QbusSwitch(QbusEntity, SwitchEntity): state.write_value(False) await self._async_publish_output_state(state) - self._attr_is_on = False async def _state_received(self, msg: ReceiveMessage) -> None: output = self._message_factory.parse_output_state( diff --git a/homeassistant/components/qnap/config_flow.py b/homeassistant/components/qnap/config_flow.py index 75f41a27f69..504883b55e9 100644 --- a/homeassistant/components/qnap/config_flow.py +++ b/homeassistant/components/qnap/config_flow.py @@ -70,8 +70,8 @@ class QnapConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except TypeError: errors["base"] = "invalid_auth" - except Exception as error: # noqa: BLE001 - _LOGGER.error(error) + except Exception: + _LOGGER.exception("Unexpected error") errors["base"] = "unknown" else: unique_id = stats["system"]["serial_number"] diff --git a/homeassistant/components/qnap/coordinator.py b/homeassistant/components/qnap/coordinator.py index 297f6569d2b..a6d654ddbbd 100644 --- a/homeassistant/components/qnap/coordinator.py +++ b/homeassistant/components/qnap/coordinator.py @@ -2,11 +2,13 @@ from __future__ import annotations +from contextlib import contextmanager, nullcontext from datetime import timedelta import logging from typing import Any from qnapstats import QNAPStats +import urllib3 from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,6 +30,17 @@ UPDATE_INTERVAL = timedelta(minutes=1) _LOGGER = logging.getLogger(__name__) +@contextmanager +def suppress_insecure_request_warning(): + """Context manager to suppress InsecureRequestWarning. + + Was added in here to solve the following issue, not being solved upstream. + https://github.com/colinodell/python-qnapstats/issues/96 + """ + with urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning): + yield + + class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Custom coordinator for the qnap integration.""" @@ -42,24 +55,31 @@ class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): ) protocol = "https" if config_entry.data[CONF_SSL] else "http" + self._verify_ssl = config_entry.data.get(CONF_VERIFY_SSL) + self._api = QNAPStats( f"{protocol}://{config_entry.data.get(CONF_HOST)}", config_entry.data.get(CONF_PORT), config_entry.data.get(CONF_USERNAME), config_entry.data.get(CONF_PASSWORD), - verify_ssl=config_entry.data.get(CONF_VERIFY_SSL), + verify_ssl=self._verify_ssl, timeout=config_entry.data.get(CONF_TIMEOUT), ) def _sync_update(self) -> dict[str, dict[str, Any]]: """Get the latest data from the Qnap API.""" - return { - "system_stats": self._api.get_system_stats(), - "system_health": self._api.get_system_health(), - "smart_drive_health": self._api.get_smart_disk_health(), - "volumes": self._api.get_volumes(), - "bandwidth": self._api.get_bandwidth(), - } + with ( + suppress_insecure_request_warning() + if not self._verify_ssl + else nullcontext() + ): + return { + "system_stats": self._api.get_system_stats(), + "system_health": self._api.get_system_health(), + "smart_drive_health": self._api.get_smart_disk_health(), + "volumes": self._api.get_volumes(), + "bandwidth": self._api.get_bandwidth(), + } async def _async_update_data(self) -> dict[str, dict[str, Any]]: """Get the latest data from the Qnap API.""" diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py index f4487a73b58..43959e1e42c 100644 --- a/homeassistant/components/rabbitair/config_flow.py +++ b/homeassistant/components/rabbitair/config_flow.py @@ -74,8 +74,8 @@ class RabbitAirConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_host" except TimeoutConnect: errors["base"] = "timeout_connect" - except Exception as err: # noqa: BLE001 - _LOGGER.debug("Unexpected exception: %s", err) + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: user_input[CONF_MAC] = info["mac"] diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 58427b0e5ba..6f4cbf4f02c 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -59,7 +59,7 @@ async def async_setup_entry( coordinator, SensorEntityDescription( key="zigbee:Price", - translation_key="meter_price", + translation_key="energy_price", native_unit_of_measurement=f"{coordinator.data['zigbee:PriceCurrency']}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index 7b5054bfb0f..08e237d5af0 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -5,7 +5,7 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "cloud_id": "Cloud ID", - "install_code": "Installation Code" + "install_code": "Installation code" }, "data_description": { "host": "The hostname or IP address of your Rainforest gateway." @@ -24,16 +24,16 @@ "entity": { "sensor": { "power_demand": { - "name": "Meter power demand" + "name": "Power demand" }, "total_energy_delivered": { - "name": "Total meter energy delivered" + "name": "Total energy delivered" }, "total_energy_received": { - "name": "Total meter energy received" + "name": "Total energy received" }, - "meter_price": { - "name": "Meter price" + "energy_price": { + "name": "Energy price" } } } diff --git a/homeassistant/components/rainforest_raven/sensor.py b/homeassistant/components/rainforest_raven/sensor.py index 3d358322b70..658689c7e6c 100644 --- a/homeassistant/components/rainforest_raven/sensor.py +++ b/homeassistant/components/rainforest_raven/sensor.py @@ -101,7 +101,7 @@ async def async_setup_entry( coordinator, RAVEnSensorEntityDescription( message_key="PriceCluster", - translation_key="meter_price", + translation_key="energy_price", key="price", native_unit_of_measurement=f"{meter_data['PriceCluster']['currency'].value}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/rainforest_raven/strings.json b/homeassistant/components/rainforest_raven/strings.json index fb667d64d3f..bc2653aea87 100644 --- a/homeassistant/components/rainforest_raven/strings.json +++ b/homeassistant/components/rainforest_raven/strings.json @@ -12,7 +12,7 @@ "step": { "meters": { "data": { - "mac": "Meter MAC Addresses" + "mac": "Meter MAC addresses" } }, "user": { @@ -24,27 +24,27 @@ }, "entity": { "sensor": { - "meter_price": { - "name": "Meter price", + "energy_price": { + "name": "Energy price", "state_attributes": { "rate_label": { "name": "Rate" }, "tier": { "name": "Tier" } } }, "power_demand": { - "name": "Meter power demand" + "name": "Power demand" }, "signal_strength": { - "name": "Meter signal strength", + "name": "Signal strength", "state_attributes": { "channel": { "name": "Channel" } } }, "total_energy_delivered": { - "name": "Total meter energy delivered" + "name": "Total energy delivered" }, "total_energy_received": { - "name": "Total meter energy received" + "name": "Total energy received" } } } diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 36ff63a0496..4797eecda0f 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -54,6 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36 EVENT_TYPE_IDS_SCHEMA_VERSION = 37 STATES_META_SCHEMA_VERSION = 38 LAST_REPORTED_SCHEMA_VERSION = 43 +CIRCULAR_MEAN_SCHEMA_VERSION = 49 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43 diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 62afa0e7b04..7b8043b9201 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -79,7 +79,13 @@ from .db_schema import ( StatisticsShortTerm, ) from .executor import DBInterruptibleThreadPoolExecutor -from .models import DatabaseEngine, StatisticData, StatisticMetaData, UnsupportedDialect +from .models import ( + DatabaseEngine, + StatisticData, + StatisticMeanType, + StatisticMetaData, + UnsupportedDialect, +) from .pool import POOL_SIZE, MutexPool, RecorderPool from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager @@ -611,6 +617,17 @@ class Recorder(threading.Thread): table: type[Statistics | StatisticsShortTerm], ) -> None: """Schedule import of statistics.""" + if "mean_type" not in metadata: + # Backwards compatibility for old metadata format + # Can be removed after 2026.4 + metadata["mean_type"] = ( # type: ignore[unreachable] + StatisticMeanType.ARITHMETIC + if metadata.get("has_mean") + else StatisticMeanType.NONE + ) + # Remove deprecated has_mean as it's not needed anymore in core + metadata.pop("has_mean", None) + self.queue_task(ImportStatisticsTask(metadata, stats, table)) @callback diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index bc8fcd1310e..6566cadf64c 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -58,6 +58,7 @@ from .const import ALL_DOMAIN_EXCLUDE_ATTRS, SupportedDialect from .models import ( StatisticData, StatisticDataTimestamp, + StatisticMeanType, StatisticMetaData, bytes_to_ulid_or_none, bytes_to_uuid_hex_or_none, @@ -77,7 +78,7 @@ class LegacyBase(DeclarativeBase): """Base class for tables, used for schema migration.""" -SCHEMA_VERSION = 48 +SCHEMA_VERSION = 50 _LOGGER = logging.getLogger(__name__) @@ -719,6 +720,7 @@ class StatisticsBase: start: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) start_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True) mean: Mapped[float | None] = mapped_column(DOUBLE_TYPE) + mean_weight: Mapped[float | None] = mapped_column(DOUBLE_TYPE) min: Mapped[float | None] = mapped_column(DOUBLE_TYPE) max: Mapped[float | None] = mapped_column(DOUBLE_TYPE) last_reset: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) @@ -740,6 +742,7 @@ class StatisticsBase: start=None, start_ts=stats["start"].timestamp(), mean=stats.get("mean"), + mean_weight=stats.get("mean_weight"), min=stats.get("min"), max=stats.get("max"), last_reset=None, @@ -763,6 +766,7 @@ class StatisticsBase: start=None, start_ts=stats["start_ts"], mean=stats.get("mean"), + mean_weight=stats.get("mean_weight"), min=stats.get("min"), max=stats.get("max"), last_reset=None, @@ -848,6 +852,9 @@ class _StatisticsMeta: has_mean: Mapped[bool | None] = mapped_column(Boolean) has_sum: Mapped[bool | None] = mapped_column(Boolean) name: Mapped[str | None] = mapped_column(String(255)) + mean_type: Mapped[StatisticMeanType] = mapped_column( + SmallInteger, nullable=False, default=StatisticMeanType.NONE.value + ) # See StatisticMeanType @staticmethod def from_meta(meta: StatisticMetaData) -> StatisticsMeta: diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index f5336e2a85b..82fdeaca045 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.39", + "SQLAlchemy==2.0.40", "fnv-hash-fast==1.4.0", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index c5eea0f7088..58af15c2aa7 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -81,7 +81,7 @@ from .db_schema import ( StatisticsRuns, StatisticsShortTerm, ) -from .models import process_timestamp +from .models import StatisticMeanType, process_timestamp from .models.time import datetime_to_timestamp_or_none from .queries import ( batch_cleanup_entity_ids, @@ -144,24 +144,32 @@ class _ColumnTypesForDialect: big_int_type: str timestamp_type: str context_bin_type: str + small_int_type: str + double_type: str _MYSQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER(20)", timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type=f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})", + small_int_type="SMALLINT", + double_type=DOUBLE_PRECISION_TYPE_SQL, ) _POSTGRESQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER", timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type="BYTEA", + small_int_type="SMALLINT", + double_type=DOUBLE_PRECISION_TYPE_SQL, ) _SQLITE_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER", timestamp_type="FLOAT", context_bin_type="BLOB", + small_int_type="INTEGER", + double_type="FLOAT", ) _COLUMN_TYPES_FOR_DIALECT: dict[SupportedDialect | None, _ColumnTypesForDialect] = { @@ -1993,6 +2001,42 @@ class _SchemaVersion48Migrator(_SchemaVersionMigrator, target_version=48): _migrate_columns_to_timestamp(self.instance, self.session_maker, self.engine) +class _SchemaVersion49Migrator(_SchemaVersionMigrator, target_version=49): + def _apply_update(self) -> None: + """Version specific update method.""" + _add_columns( + self.session_maker, + "statistics_meta", + [ + f"mean_type {self.column_types.small_int_type} NOT NULL DEFAULT {StatisticMeanType.NONE.value}" + ], + ) + + for table in ("statistics", "statistics_short_term"): + _add_columns( + self.session_maker, + table, + [f"mean_weight {self.column_types.double_type}"], + ) + + with session_scope(session=self.session_maker()) as session: + connection = session.connection() + connection.execute( + text( + "UPDATE statistics_meta SET mean_type=:mean_type WHERE has_mean=true" + ), + {"mean_type": StatisticMeanType.ARITHMETIC.value}, + ) + + +class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50): + def _apply_update(self) -> None: + """Version specific update method.""" + with session_scope(session=self.session_maker()) as session: + connection = session.connection() + connection.execute(text("UPDATE statistics_meta SET has_mean=NULL")) + + def _migrate_statistics_columns_to_timestamp_removing_duplicates( hass: HomeAssistant, instance: Recorder, diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py index ea7a6c86854..8f76982a900 100644 --- a/homeassistant/components/recorder/models/__init__.py +++ b/homeassistant/components/recorder/models/__init__.py @@ -17,6 +17,7 @@ from .statistics import ( RollingWindowStatisticPeriod, StatisticData, StatisticDataTimestamp, + StatisticMeanType, StatisticMetaData, StatisticPeriod, StatisticResult, @@ -37,6 +38,7 @@ __all__ = [ "RollingWindowStatisticPeriod", "StatisticData", "StatisticDataTimestamp", + "StatisticMeanType", "StatisticMetaData", "StatisticPeriod", "StatisticResult", diff --git a/homeassistant/components/recorder/models/statistics.py b/homeassistant/components/recorder/models/statistics.py index ad4d82067c4..08da12d6b17 100644 --- a/homeassistant/components/recorder/models/statistics.py +++ b/homeassistant/components/recorder/models/statistics.py @@ -3,7 +3,8 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import Literal, TypedDict +from enum import IntEnum +from typing import Literal, NotRequired, TypedDict class StatisticResult(TypedDict): @@ -36,6 +37,7 @@ class StatisticMixIn(TypedDict, total=False): min: float max: float mean: float + mean_weight: float class StatisticData(StatisticDataBase, StatisticMixIn, total=False): @@ -50,10 +52,20 @@ class StatisticDataTimestamp(StatisticDataTimestampBase, StatisticMixIn, total=F last_reset_ts: float | None +class StatisticMeanType(IntEnum): + """Statistic mean type.""" + + NONE = 0 + ARITHMETIC = 1 + CIRCULAR = 2 + + class StatisticMetaData(TypedDict): """Statistic meta data class.""" - has_mean: bool + # has_mean is deprecated, use mean_type instead. has_mean will be removed in 2026.4 + has_mean: NotRequired[bool] + mean_type: StatisticMeanType has_sum: bool name: str | None source: str diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index e26a69c0db9..80c0028ef7a 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -9,12 +9,23 @@ from datetime import datetime, timedelta from functools import lru_cache, partial from itertools import chain, groupby import logging +import math from operator import itemgetter import re from time import time as time_time -from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, Required, TypedDict, cast -from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text +from sqlalchemy import ( + Label, + Select, + and_, + bindparam, + case, + func, + lambda_stmt, + select, + text, +) from sqlalchemy.engine.row import Row from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm.session import Session @@ -29,6 +40,7 @@ from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.collection import chunked_or_all +from homeassistant.util.enum import try_parse_enum from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, @@ -74,6 +86,7 @@ from .db_schema import ( from .models import ( StatisticData, StatisticDataTimestamp, + StatisticMeanType, StatisticMetaData, StatisticResult, datetime_to_timestamp_or_none, @@ -113,11 +126,53 @@ QUERY_STATISTICS_SHORT_TERM = ( StatisticsShortTerm.sum, ) + +def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]: + """Return the sqlalchemy function for circular mean and the mean_weight. + + The result must be modulo 360 to normalize the result [0, 360]. + """ + # Postgres doesn't support modulo for double precision and + # the other dbs return the remainder instead of the modulo + # meaning negative values are possible. For these reason + # we need to normalize the result to be in the range [0, 360) + # in Python. + # https://en.wikipedia.org/wiki/Circular_mean + radians = func.radians(table.mean) + weighted_sum_sin = func.sum(func.sin(radians) * table.mean_weight) + weighted_sum_cos = func.sum(func.cos(radians) * table.mean_weight) + weight = func.sqrt( + func.power(weighted_sum_sin, 2) + func.power(weighted_sum_cos, 2) + ) + return ( + func.degrees(func.atan2(weighted_sum_sin, weighted_sum_cos)).label("mean"), + weight.label("mean_weight"), + ) + + QUERY_STATISTICS_SUMMARY_MEAN = ( StatisticsShortTerm.metadata_id, - func.avg(StatisticsShortTerm.mean), func.min(StatisticsShortTerm.min), func.max(StatisticsShortTerm.max), + case( + ( + StatisticsMeta.mean_type == StatisticMeanType.ARITHMETIC, + func.avg(StatisticsShortTerm.mean), + ), + ( + StatisticsMeta.mean_type == StatisticMeanType.CIRCULAR, + query_circular_mean(StatisticsShortTerm)[0], + ), + else_=None, + ), + case( + ( + StatisticsMeta.mean_type == StatisticMeanType.CIRCULAR, + query_circular_mean(StatisticsShortTerm)[1], + ), + else_=None, + ), + StatisticsMeta.mean_type, ) QUERY_STATISTICS_SUMMARY_SUM = ( @@ -180,6 +235,26 @@ def mean(values: list[float]) -> float | None: return sum(values) / len(values) +DEG_TO_RAD = math.pi / 180 +RAD_TO_DEG = 180 / math.pi + + +def weighted_circular_mean( + values: Iterable[tuple[float, float]], +) -> tuple[float, float]: + """Return the weighted circular mean and the weight of the values.""" + weighted_sin_sum, weighted_cos_sum = 0.0, 0.0 + for x, weight in values: + rad_x = x * DEG_TO_RAD + weighted_sin_sum += math.sin(rad_x) * weight + weighted_cos_sum += math.cos(rad_x) * weight + + return ( + (RAD_TO_DEG * math.atan2(weighted_sin_sum, weighted_cos_sum)) % 360, + math.sqrt(weighted_sin_sum**2 + weighted_cos_sum**2), + ) + + _LOGGER = logging.getLogger(__name__) @@ -226,6 +301,7 @@ class StatisticsRow(BaseStatisticsRow, total=False): min: float | None max: float | None mean: float | None + mean_weight: float | None change: float | None @@ -372,11 +448,19 @@ def _compile_hourly_statistics_summary_mean_stmt( start_time_ts: float, end_time_ts: float ) -> StatementLambdaElement: """Generate the summary mean statement for hourly statistics.""" + # Due the fact that we support different mean type (See StatisticMeanType) + # we need to join here with the StatisticsMeta table to get the mean type + # and then use a case statement to compute the mean based on the mean type. + # As we use the StatisticsMeta.mean_type in the select case statement we need + # to group by it as well. return lambda_stmt( lambda: select(*QUERY_STATISTICS_SUMMARY_MEAN) .filter(StatisticsShortTerm.start_ts >= start_time_ts) .filter(StatisticsShortTerm.start_ts < end_time_ts) - .group_by(StatisticsShortTerm.metadata_id) + .join( + StatisticsMeta, and_(StatisticsShortTerm.metadata_id == StatisticsMeta.id) + ) + .group_by(StatisticsShortTerm.metadata_id, StatisticsMeta.mean_type) .order_by(StatisticsShortTerm.metadata_id) ) @@ -418,10 +502,17 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None: if stats: for stat in stats: - metadata_id, _mean, _min, _max = stat + metadata_id, _min, _max, _mean, _mean_weight, _mean_type = stat + if ( + try_parse_enum(StatisticMeanType, _mean_type) + is StatisticMeanType.CIRCULAR + ): + # Normalize the circular mean to be in the range [0, 360) + _mean = _mean % 360 summary[metadata_id] = { "start_ts": start_time_ts, "mean": _mean, + "mean_weight": _mean_weight, "min": _min, "max": _max, } @@ -827,7 +918,7 @@ def _statistic_by_id_from_metadata( "display_unit_of_measurement": get_display_unit( hass, meta["statistic_id"], meta["unit_of_measurement"] ), - "has_mean": meta["has_mean"], + "mean_type": meta["mean_type"], "has_sum": meta["has_sum"], "name": meta["name"], "source": meta["source"], @@ -846,7 +937,9 @@ def _flatten_list_statistic_ids_metadata_result( { "statistic_id": _id, "display_unit_of_measurement": info["display_unit_of_measurement"], - "has_mean": info["has_mean"], + "has_mean": info["mean_type"] + == StatisticMeanType.ARITHMETIC, # Can be removed with 2026.4 + "mean_type": info["mean_type"], "has_sum": info["has_sum"], "name": info.get("name"), "source": info["source"], @@ -901,7 +994,7 @@ def list_statistic_ids( continue result[key] = { "display_unit_of_measurement": meta["unit_of_measurement"], - "has_mean": meta["has_mean"], + "mean_type": meta["mean_type"], "has_sum": meta["has_sum"], "name": meta["name"], "source": meta["source"], @@ -919,6 +1012,7 @@ def _reduce_statistics( period_start_end: Callable[[float], tuple[float, float]], period: timedelta, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily or monthly statistics.""" result: dict[str, list[StatisticsRow]] = defaultdict(list) @@ -931,7 +1025,7 @@ def _reduce_statistics( _want_sum = "sum" in types for statistic_id, stat_list in stats.items(): max_values: list[float] = [] - mean_values: list[float] = [] + mean_values: list[tuple[float, float]] = [] min_values: list[float] = [] prev_stat: StatisticsRow = stat_list[0] fake_entry: StatisticsRow = {"start": stat_list[-1]["start"] + period_seconds} @@ -946,7 +1040,16 @@ def _reduce_statistics( "end": end, } if _want_mean: - row["mean"] = mean(mean_values) if mean_values else None + row["mean"] = None + row["mean_weight"] = None + if mean_values: + match metadata[statistic_id][1]["mean_type"]: + case StatisticMeanType.ARITHMETIC: + row["mean"] = mean([x[0] for x in mean_values]) + case StatisticMeanType.CIRCULAR: + row["mean"], row["mean_weight"] = ( + weighted_circular_mean(mean_values) + ) mean_values.clear() if _want_min: row["min"] = min(min_values) if min_values else None @@ -963,8 +1066,10 @@ def _reduce_statistics( result[statistic_id].append(row) if _want_max and (_max := statistic.get("max")) is not None: max_values.append(_max) - if _want_mean and (_mean := statistic.get("mean")) is not None: - mean_values.append(_mean) + if _want_mean: + if (_mean := statistic.get("mean")) is not None: + _mean_weight = statistic.get("mean_weight") or 0.0 + mean_values.append((_mean, _mean_weight)) if _want_min and (_min := statistic.get("min")) is not None: min_values.append(_min) prev_stat = statistic @@ -1011,11 +1116,12 @@ def reduce_day_ts_factory() -> tuple[ def _reduce_statistics_per_day( stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily statistics.""" _same_day_ts, _day_start_end_ts = reduce_day_ts_factory() return _reduce_statistics( - stats, _same_day_ts, _day_start_end_ts, timedelta(days=1), types + stats, _same_day_ts, _day_start_end_ts, timedelta(days=1), types, metadata ) @@ -1059,11 +1165,12 @@ def reduce_week_ts_factory() -> tuple[ def _reduce_statistics_per_week( stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to weekly statistics.""" _same_week_ts, _week_start_end_ts = reduce_week_ts_factory() return _reduce_statistics( - stats, _same_week_ts, _week_start_end_ts, timedelta(days=7), types + stats, _same_week_ts, _week_start_end_ts, timedelta(days=7), types, metadata ) @@ -1112,11 +1219,12 @@ def reduce_month_ts_factory() -> tuple[ def _reduce_statistics_per_month( stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to monthly statistics.""" _same_month_ts, _month_start_end_ts = reduce_month_ts_factory() return _reduce_statistics( - stats, _same_month_ts, _month_start_end_ts, timedelta(days=31), types + stats, _same_month_ts, _month_start_end_ts, timedelta(days=31), types, metadata ) @@ -1160,27 +1268,41 @@ def _generate_max_mean_min_statistic_in_sub_period_stmt( return stmt +class _MaxMinMeanStatisticSubPeriod(TypedDict, total=False): + max: float + mean_acc: float + min: float + duration: float + circular_means: Required[list[tuple[float, float]]] + + def _get_max_mean_min_statistic_in_sub_period( session: Session, - result: dict[str, float], + result: _MaxMinMeanStatisticSubPeriod, start_time: datetime | None, end_time: datetime | None, table: type[StatisticsBase], types: set[Literal["max", "mean", "min", "change"]], - metadata_id: int, + metadata: tuple[int, StatisticMetaData], ) -> None: """Return max, mean and min during the period.""" # Calculate max, mean, min + mean_type = metadata[1]["mean_type"] columns = select() if "max" in types: columns = columns.add_columns(func.max(table.max)) if "mean" in types: - columns = columns.add_columns(func.avg(table.mean)) - columns = columns.add_columns(func.count(table.mean)) + match mean_type: + case StatisticMeanType.ARITHMETIC: + columns = columns.add_columns(func.avg(table.mean)) + columns = columns.add_columns(func.count(table.mean)) + case StatisticMeanType.CIRCULAR: + columns = columns.add_columns(*query_circular_mean(table)) if "min" in types: columns = columns.add_columns(func.min(table.min)) + stmt = _generate_max_mean_min_statistic_in_sub_period_stmt( - columns, start_time, end_time, table, metadata_id + columns, start_time, end_time, table, metadata[0] ) stats = cast(Sequence[Row[Any]], execute_stmt_lambda_element(session, stmt)) if not stats: @@ -1188,11 +1310,21 @@ def _get_max_mean_min_statistic_in_sub_period( if "max" in types and (new_max := stats[0].max) is not None: old_max = result.get("max") result["max"] = max(new_max, old_max) if old_max is not None else new_max - if "mean" in types and stats[0].avg is not None: + if "mean" in types: # https://github.com/sqlalchemy/sqlalchemy/issues/9127 - duration = stats[0].count * table.duration.total_seconds() # type: ignore[operator] - result["duration"] = result.get("duration", 0.0) + duration - result["mean_acc"] = result.get("mean_acc", 0.0) + stats[0].avg * duration + match mean_type: + case StatisticMeanType.ARITHMETIC: + duration = stats[0].count * table.duration.total_seconds() # type: ignore[operator] + if stats[0].avg is not None: + result["duration"] = result.get("duration", 0.0) + duration + result["mean_acc"] = ( + result.get("mean_acc", 0.0) + stats[0].avg * duration + ) + case StatisticMeanType.CIRCULAR: + if (new_circular_mean := stats[0].mean) is not None and ( + weight := stats[0].mean_weight + ) is not None: + result["circular_means"].append((new_circular_mean, weight)) if "min" in types and (new_min := stats[0].min) is not None: old_min = result.get("min") result["min"] = min(new_min, old_min) if old_min is not None else new_min @@ -1207,15 +1339,15 @@ def _get_max_mean_min_statistic( tail_start_time: datetime | None, tail_end_time: datetime | None, tail_only: bool, - metadata_id: int, + metadata: tuple[int, StatisticMetaData], types: set[Literal["max", "mean", "min", "change"]], ) -> dict[str, float | None]: """Return max, mean and min during the period. - The mean is a time weighted average, combining hourly and 5-minute statistics if + The mean is time weighted, combining hourly and 5-minute statistics if necessary. """ - max_mean_min: dict[str, float] = {} + max_mean_min = _MaxMinMeanStatisticSubPeriod(circular_means=[]) result: dict[str, float | None] = {} if tail_start_time is not None: @@ -1227,7 +1359,7 @@ def _get_max_mean_min_statistic( tail_end_time, StatisticsShortTerm, types, - metadata_id, + metadata, ) if not tail_only: @@ -1238,7 +1370,7 @@ def _get_max_mean_min_statistic( main_end_time, Statistics, types, - metadata_id, + metadata, ) if head_start_time is not None: @@ -1249,16 +1381,23 @@ def _get_max_mean_min_statistic( head_end_time, StatisticsShortTerm, types, - metadata_id, + metadata, ) if "max" in types: result["max"] = max_mean_min.get("max") if "mean" in types: - if "mean_acc" not in max_mean_min: - result["mean"] = None - else: - result["mean"] = max_mean_min["mean_acc"] / max_mean_min["duration"] + mean_value = None + match metadata[1]["mean_type"]: + case StatisticMeanType.CIRCULAR: + if circular_means := max_mean_min["circular_means"]: + mean_value = weighted_circular_mean(circular_means)[0] + case StatisticMeanType.ARITHMETIC: + if (mean_value := max_mean_min.get("mean_acc")) is not None and ( + duration := max_mean_min.get("duration") + ) is not None: + mean_value = mean_value / duration + result["mean"] = mean_value if "min" in types: result["min"] = max_mean_min.get("min") return result @@ -1559,7 +1698,7 @@ def statistic_during_period( tail_start_time, tail_end_time, tail_only, - metadata_id, + metadata, types, ) @@ -1606,12 +1745,12 @@ def statistic_during_period( _type_column_mapping = { - "last_reset": "last_reset_ts", - "max": "max", - "mean": "mean", - "min": "min", - "state": "state", - "sum": "sum", + "last_reset": ("last_reset_ts",), + "max": ("max",), + "mean": ("mean", "mean_weight"), + "min": ("min",), + "state": ("state",), + "sum": ("sum",), } @@ -1623,12 +1762,13 @@ def _generate_select_columns_for_types_stmt( track_on: list[str | None] = [ table.__tablename__, # type: ignore[attr-defined] ] - for key, column in _type_column_mapping.items(): - if key in types: - columns = columns.add_columns(getattr(table, column)) - track_on.append(column) - else: - track_on.append(None) + for key, type_columns in _type_column_mapping.items(): + for column in type_columns: + if key in types: + columns = columns.add_columns(getattr(table, column)) + track_on.append(column) + else: + track_on.append(None) return lambda_stmt(lambda: columns, track_on=track_on) @@ -1642,7 +1782,7 @@ def _extract_metadata_and_discard_impossible_columns( has_sum = False for metadata_id, stats_metadata in metadata.values(): metadata_ids.append(metadata_id) - has_mean |= stats_metadata["has_mean"] + has_mean |= stats_metadata["mean_type"] is not StatisticMeanType.NONE has_sum |= stats_metadata["has_sum"] if not has_mean: types.discard("mean") @@ -1798,19 +1938,25 @@ def _statistics_during_period_with_session( ) if period == "day": - result = _reduce_statistics_per_day(result, types) + result = _reduce_statistics_per_day(result, types, metadata) if period == "week": - result = _reduce_statistics_per_week(result, types) + result = _reduce_statistics_per_week(result, types, metadata) if period == "month": - result = _reduce_statistics_per_month(result, types) + result = _reduce_statistics_per_month(result, types, metadata) if "change" in _types: _augment_result_with_change( hass, session, start_time, units, _types, table, metadata, result ) + # filter out mean_weight as it is only needed to reduce statistics + # and not needed in the result + for stats_rows in result.values(): + for row in stats_rows: + row.pop("mean_weight", None) + # Return statistics combined with metadata return result @@ -2258,7 +2404,12 @@ def _sorted_statistics_to_dict( field_map["last_reset"] = field_map.pop("last_reset_ts") sum_idx = field_map["sum"] if "sum" in types else None sum_only = len(types) == 1 and sum_idx is not None - row_mapping = tuple((key, field_map[key]) for key in types if key in field_map) + row_mapping = tuple( + (column, field_map[column]) + for key in types + for column in ({key, *_type_column_mapping.get(key, ())}) + if column in field_map + ) # Append all statistic entries, and optionally do unit conversion table_duration_seconds = table.duration.total_seconds() for meta_id, db_rows in stats_by_meta_id.items(): diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 43c2ecdc14f..0c8d47548bf 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -43,15 +43,15 @@ "fields": { "entity_id": { "name": "Entities to remove", - "description": "List of entities for which the data is to be removed from the recorder database." + "description": "List of entities for which the data is to be removed from the Recorder database." }, "domains": { "name": "Domains to remove", - "description": "List of domains for which the data needs to be removed from the recorder database." + "description": "List of domains for which the data needs to be removed from the Recorder database." }, "entity_globs": { "name": "Entity globs to remove", - "description": "List of glob patterns used to select the entities for which the data is to be removed from the recorder database." + "description": "List of glob patterns used to select the entities for which the data is to be removed from the Recorder database." }, "keep_days": { "name": "[%key:component::recorder::services::purge::fields::keep_days::name%]", diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index 77fc34518db..634e9565c12 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -4,16 +4,18 @@ from __future__ import annotations import logging import threading -from typing import TYPE_CHECKING, Final, Literal +from typing import TYPE_CHECKING, Any, Final, Literal from lru import LRU from sqlalchemy import lambda_stmt, select +from sqlalchemy.orm import InstrumentedAttribute from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import true from sqlalchemy.sql.lambdas import StatementLambdaElement +from ..const import CIRCULAR_MEAN_SCHEMA_VERSION from ..db_schema import StatisticsMeta -from ..models import StatisticMetaData +from ..models import StatisticMeanType, StatisticMetaData from ..util import execute_stmt_lambda_element if TYPE_CHECKING: @@ -28,7 +30,6 @@ QUERY_STATISTIC_META = ( StatisticsMeta.statistic_id, StatisticsMeta.source, StatisticsMeta.unit_of_measurement, - StatisticsMeta.has_mean, StatisticsMeta.has_sum, StatisticsMeta.name, ) @@ -37,24 +38,38 @@ INDEX_ID: Final = 0 INDEX_STATISTIC_ID: Final = 1 INDEX_SOURCE: Final = 2 INDEX_UNIT_OF_MEASUREMENT: Final = 3 -INDEX_HAS_MEAN: Final = 4 -INDEX_HAS_SUM: Final = 5 -INDEX_NAME: Final = 6 +INDEX_HAS_SUM: Final = 4 +INDEX_NAME: Final = 5 +INDEX_MEAN_TYPE: Final = 6 def _generate_get_metadata_stmt( statistic_ids: set[str] | None = None, statistic_type: Literal["mean", "sum"] | None = None, statistic_source: str | None = None, + schema_version: int = 0, ) -> StatementLambdaElement: - """Generate a statement to fetch metadata.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) + """Generate a statement to fetch metadata with the passed filters. + + Depending on the schema version, either mean_type (added in version 49) or has_mean column is used. + """ + columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META) + if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: + columns.append(StatisticsMeta.mean_type) + else: + columns.append(StatisticsMeta.has_mean) + stmt = lambda_stmt(lambda: select(*columns)) if statistic_ids: stmt += lambda q: q.where(StatisticsMeta.statistic_id.in_(statistic_ids)) if statistic_source is not None: stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) if statistic_type == "mean": - stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) + if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: + stmt += lambda q: q.where( + StatisticsMeta.mean_type != StatisticMeanType.NONE + ) + else: + stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) elif statistic_type == "sum": stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) return stmt @@ -100,14 +115,34 @@ class StatisticsMetaManager: for row in execute_stmt_lambda_element( session, _generate_get_metadata_stmt( - statistic_ids, statistic_type, statistic_source + statistic_ids, + statistic_type, + statistic_source, + self.recorder.schema_version, ), orm_rows=False, ): statistic_id = row[INDEX_STATISTIC_ID] row_id = row[INDEX_ID] + if self.recorder.schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: + try: + mean_type = StatisticMeanType(row[INDEX_MEAN_TYPE]) + except ValueError: + _LOGGER.warning( + "Invalid mean type found for statistic_id: %s, mean_type: %s. Skipping", + statistic_id, + row[INDEX_MEAN_TYPE], + ) + continue + else: + mean_type = ( + StatisticMeanType.ARITHMETIC + if row[INDEX_MEAN_TYPE] + else StatisticMeanType.NONE + ) meta = { - "has_mean": row[INDEX_HAS_MEAN], + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": row[INDEX_HAS_SUM], "name": row[INDEX_NAME], "source": row[INDEX_SOURCE], @@ -157,9 +192,18 @@ class StatisticsMetaManager: This call is not thread-safe and must be called from the recorder thread. """ + if "mean_type" not in new_metadata: + # To maintain backward compatibility after adding 'mean_type' in schema version 49, + # we must still check for its presence. Even though type hints suggest it should always exist, + # custom integrations might omit it, so we need to guard against that. + new_metadata["mean_type"] = ( # type: ignore[unreachable] + StatisticMeanType.ARITHMETIC + if new_metadata["has_mean"] + else StatisticMeanType.NONE + ) metadata_id, old_metadata = old_metadata_dict[statistic_id] if not ( - old_metadata["has_mean"] != new_metadata["has_mean"] + old_metadata["mean_type"] != new_metadata["mean_type"] or old_metadata["has_sum"] != new_metadata["has_sum"] or old_metadata["name"] != new_metadata["name"] or old_metadata["unit_of_measurement"] @@ -170,7 +214,7 @@ class StatisticsMetaManager: self._assert_in_recorder_thread() session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( { - StatisticsMeta.has_mean: new_metadata["has_mean"], + StatisticsMeta.mean_type: new_metadata["mean_type"], StatisticsMeta.has_sum: new_metadata["has_sum"], StatisticsMeta.name: new_metadata["name"], StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index d23ecab3dac..f4058943971 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -37,7 +37,7 @@ from homeassistant.util.unit_conversion import ( VolumeFlowRateConverter, ) -from .models import StatisticPeriod +from .models import StatisticMeanType, StatisticPeriod from .statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, async_add_external_statistics, @@ -532,6 +532,10 @@ def ws_import_statistics( ) -> None: """Import statistics.""" metadata = msg["metadata"] + # The WS command will be changed in a follow up PR + metadata["mean_type"] = ( + StatisticMeanType.ARITHMETIC if metadata["has_mean"] else StatisticMeanType.NONE + ) stats = msg["stats"] if valid_entity_id(metadata["statistic_id"]): diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py index 03d0e7ea96a..802a7eb7cea 100644 --- a/homeassistant/components/remote_calendar/config_flow.py +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Remote Calendar integration.""" +from http import HTTPStatus import logging from typing import Any @@ -42,10 +43,21 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match( {CONF_CALENDAR_NAME: user_input[CONF_CALENDAR_NAME]} ) + if user_input[CONF_URL].startswith("webcal://"): + user_input[CONF_URL] = user_input[CONF_URL].replace( + "webcal://", "https://", 1 + ) self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) client = get_async_client(self.hass) try: res = await client.get(user_input[CONF_URL], follow_redirects=True) + if res.status_code == HTTPStatus.FORBIDDEN: + errors["base"] = "forbidden" + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) res.raise_for_status() except (HTTPError, InvalidURL) as err: errors["base"] = "cannot_connect" @@ -57,7 +69,10 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): ) except CalendarParseError as err: errors["base"] = "invalid_ics_file" - _LOGGER.debug("Invalid .ics file: %s", err) + _LOGGER.error("Error reading the calendar information: %s", err.message) + _LOGGER.debug( + "Additional calendar error detail: %s", str(err.detailed_error) + ) else: return self.async_create_entry( title=user_input[CONF_CALENDAR_NAME], data=user_input diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py index 7f29f7e2ea8..6caec297c1a 100644 --- a/homeassistant/components/remote_calendar/coordinator.py +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -26,6 +26,7 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): """Class to manage fetching calendar data.""" config_entry: RemoteCalendarConfigEntry + ics: str def __init__( self, @@ -40,7 +41,6 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): update_interval=SCAN_INTERVAL, always_update=True, ) - self._etag = None self._client = get_async_client(hass) self._url = config_entry.data[CONF_URL] @@ -59,8 +59,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): # calendar_from_ics will dynamically load packages # the first time it is called, so we need to do it # in a separate thread to avoid blocking the event loop + self.ics = res.text return await self.hass.async_add_executor_job( - IcsCalendarStream.calendar_from_ics, res.text + IcsCalendarStream.calendar_from_ics, self.ics ) except CalendarParseError as err: raise UpdateFailed( diff --git a/homeassistant/components/remote_calendar/diagnostics.py b/homeassistant/components/remote_calendar/diagnostics.py new file mode 100644 index 00000000000..5ebfb3d3812 --- /dev/null +++ b/homeassistant/components/remote_calendar/diagnostics.py @@ -0,0 +1,25 @@ +"""Provides diagnostics for the remote calendar.""" + +import datetime +from typing import Any + +from ical.diagnostics import redact_ics + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import RemoteCalendarConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: RemoteCalendarConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + payload: dict[str, Any] = { + "now": dt_util.now().isoformat(), + "timezone": str(dt_util.get_default_time_zone()), + "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), + } + payload["ics"] = "\n".join(redact_ics(coordinator.ics)) + return payload diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index fe17a3d2c34..da078395484 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==9.0.1"] + "requirements": ["ical==9.1.0"] } diff --git a/homeassistant/components/remote_calendar/quality_scale.yaml b/homeassistant/components/remote_calendar/quality_scale.yaml index 05dc32e5da9..964b63d7116 100644 --- a/homeassistant/components/remote_calendar/quality_scale.yaml +++ b/homeassistant/components/remote_calendar/quality_scale.yaml @@ -53,9 +53,7 @@ rules: devices: status: exempt comment: No devices. One URL is always assigned to one calendar. - diagnostics: - status: todo - comment: Diagnostics not implemented, yet. + diagnostics: done discovery-update-info: status: todo comment: No discovery protocol available. diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json index 1ad62821818..ef7f20d4699 100644 --- a/homeassistant/components/remote_calendar/strings.json +++ b/homeassistant/components/remote_calendar/strings.json @@ -19,7 +19,8 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_ics_file": "[%key:component::local_calendar::config::error::invalid_ics_file%]" + "forbidden": "The server understood the request but refuses to authorize it.", + "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details." } }, "exceptions": { diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 70544a5637f..90d2c11613c 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any import aiohttp @@ -16,6 +17,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN from .renault_hub import RenaultHub +_LOGGER = logging.getLogger(__name__) + USER_SCHEMA = vol.Schema( { vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()), @@ -54,7 +57,8 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): ) except (aiohttp.ClientConnectionError, GigyaException): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: if login_success: diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 201a07c6783..05f8099b168 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -9,6 +9,9 @@ CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" DEFAULT_SCAN_INTERVAL = 420 # 7 minutes +# If throttled time to pause the updates, in seconds +COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes + PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index a90331730bc..c768c436133 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -12,6 +12,7 @@ from renault_api.kamereon.exceptions import ( AccessDeniedException, KamereonResponseException, NotSupportedException, + QuotaLimitException, ) from renault_api.kamereon.models import KamereonVehicleDataAttributes @@ -20,6 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub T = TypeVar("T", bound=KamereonVehicleDataAttributes) @@ -37,6 +39,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, logger: logging.Logger, *, name: str, @@ -54,10 +57,24 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): ) self.access_denied = False self.not_supported = False + self.assumed_state = False + self._has_already_worked = False + self._hub = hub async def _async_update_data(self) -> T: """Fetch the latest data from the source.""" + + if self._hub.is_throttled(): + if not self._has_already_worked: + raise UpdateFailed("Renault hub currently throttled: init skipped") + # we have been throttled and decided to cooldown + # so do not count this update as an error + # coordinator. last_update_success should still be ok + self.logger.debug("Renault hub currently throttled: scan skipped") + self.assumed_state = True + return self.data + try: async with _PARALLEL_SEMAPHORE: data = await self.update_method() @@ -70,6 +87,16 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self.access_denied = True raise UpdateFailed(f"This endpoint is denied: {err}") from err + except QuotaLimitException as err: + # The data we got is not bad per see, initiate cooldown for all coordinators + self._hub.set_throttled() + if self._has_already_worked: + self.assumed_state = True + self.logger.warning("Renault API throttled") + return self.data + + raise UpdateFailed(f"Renault API throttled: {err}") from err + except NotSupportedException as err: # Disable because the vehicle does not support this Renault endpoint. self.update_interval = None @@ -81,6 +108,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): raise UpdateFailed(f"Error communicating with API: {err}") from err self._has_already_worked = True + self.assumed_state = False return data async def async_config_entry_first_refresh(self) -> None: diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index 7beb91e9603..81d81a18b7f 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -60,3 +60,8 @@ class RenaultDataEntity( def _get_data_attr(self, key: str) -> StateType: """Return the attribute value from the coordinator data.""" return cast(StateType, getattr(self.coordinator.data, key)) + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return self.coordinator.assumed_state diff --git a/homeassistant/components/renault/icons.json b/homeassistant/components/renault/icons.json index 8b9c4885eaa..aa9175052fb 100644 --- a/homeassistant/components/renault/icons.json +++ b/homeassistant/components/renault/icons.json @@ -35,7 +35,7 @@ }, "sensor": { "charge_state": { - "default": "mdi:mdi:flash-off", + "default": "mdi:flash-off", "state": { "charge_in_progress": "mdi:flash" } diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index b37390526cf..e5168fc81fd 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -27,7 +27,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession if TYPE_CHECKING: from . import RenaultConfigEntry -from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL +from time import time + +from .const import ( + CONF_KAMEREON_ACCOUNT_ID, + COOLING_UPDATES_SECONDS, + DEFAULT_SCAN_INTERVAL, +) from .renault_vehicle import RenaultVehicleProxy LOGGER = logging.getLogger(__name__) @@ -45,6 +51,24 @@ class RenaultHub: self._account: RenaultAccount | None = None self._vehicles: dict[str, RenaultVehicleProxy] = {} + self._got_throttled_at_time: float | None = None + + def set_throttled(self) -> None: + """We got throttled, we need to adjust the rate limit.""" + if self._got_throttled_at_time is None: + self._got_throttled_at_time = time() + + def is_throttled(self) -> bool: + """Check if we are throttled.""" + if self._got_throttled_at_time is None: + return False + + if time() - self._got_throttled_at_time > COOLING_UPDATES_SECONDS: + self._got_throttled_at_time = None + return False + + return True + async def attempt_login(self, username: str, password: str) -> bool: """Attempt login to Renault servers.""" try: @@ -99,6 +123,7 @@ class RenaultHub: vehicle = RenaultVehicleProxy( hass=self._hass, config_entry=config_entry, + hub=self, vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), details=vehicle_link.vehicleDetails, scan_interval=scan_interval, diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 1cce0e4459f..1ab9bf0bd5a 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -20,6 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub from .const import DOMAIN from .coordinator import RenaultDataUpdateCoordinator @@ -68,6 +69,7 @@ class RenaultVehicleProxy: self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, vehicle: RenaultVehicle, details: models.KamereonVehicleDetails, scan_interval: timedelta, @@ -87,6 +89,7 @@ class RenaultVehicleProxy: self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} self.hvac_target_temperature = 21 self._scan_interval = scan_interval + self._hub = hub @property def details(self) -> models.KamereonVehicleDetails: @@ -104,6 +107,7 @@ class RenaultVehicleProxy: coord.key: RenaultDataUpdateCoordinator( self.hass, self.config_entry, + self._hub, LOGGER, name=f"{self.details.vin} {coord.key}", update_method=coord.update_method(self._vehicle), diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 8649a5c7b47..727e8cf32f1 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -118,7 +118,7 @@ "charge_ended": "Charge ended", "waiting_for_current_charge": "Waiting for current charge", "energy_flap_opened": "Energy flap opened", - "charge_in_progress": "Charging", + "charge_in_progress": "[%key:common::state::charging%]", "charge_error": "Not charging or plugged in", "unavailable": "Unavailable" } diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 39910bbc52a..95c5f1982c3 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -301,7 +301,7 @@ async def async_setup_entry( ) for entity_description in BINARY_SMART_AI_SENSORS for location in api.baichuan.smart_location_list( - channel, entity_description.key + channel, entity_description.smart_type ) if entity_description.supported(api, channel, location) ) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 55ce4ce891e..ec598de663d 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -178,8 +178,13 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): else: self._dev_id = f"{self._host.unique_id}_ch{dev_ch}" + connections = set() + if mac := self._host.api.baichuan.mac_address(dev_ch): + connections.add((CONNECTION_NETWORK_MAC, mac)) + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, + connections=connections, via_device=(DOMAIN, self._host.unique_id), name=self._host.api.camera_name(dev_ch), model=self._host.api.camera_model(dev_ch), diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 00045c4cda2..7d1dba099ed 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -217,6 +217,21 @@ "ai_animal_sensitivity": { "default": "mdi:paw" }, + "crossline_sensitivity": { + "default": "mdi:fence" + }, + "intrusion_sensitivity": { + "default": "mdi:location-enter" + }, + "linger_sensitivity": { + "default": "mdi:account-switch" + }, + "forgotten_item_sensitivity": { + "default": "mdi:package-variant-closed-plus" + }, + "taken_item_sensitivity": { + "default": "mdi:package-variant-closed-minus" + }, "ai_face_delay": { "default": "mdi:face-recognition" }, @@ -235,6 +250,18 @@ "ai_animal_delay": { "default": "mdi:paw" }, + "intrusion_delay": { + "default": "mdi:location-enter" + }, + "linger_delay": { + "default": "mdi:account-switch" + }, + "forgotten_item_delay": { + "default": "mdi:package-variant-closed-plus" + }, + "taken_item_delay": { + "default": "mdi:package-variant-closed-minus" + }, "auto_quick_reply_time": { "default": "mdi:message-reply-text-outline" }, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 41cfe1f9ae3..82b9586cccc 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.12.3"] + "requirements": ["reolink-aio==0.13.0"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 48382df4cbc..2a6fb740ee0 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -9,6 +9,7 @@ from typing import Any from reolink_aio.api import Chime, Host from homeassistant.components.number import ( + NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, @@ -44,6 +45,19 @@ class ReolinkNumberEntityDescription( value: Callable[[Host, int], float | None] +@dataclass(frozen=True, kw_only=True) +class ReolinkSmartAINumberEntityDescription( + NumberEntityDescription, + ReolinkChannelEntityDescription, +): + """A class that describes smart AI number entities.""" + + smart_type: str + method: Callable[[Host, int, int, float], Any] + mode: NumberMode = NumberMode.AUTO + value: Callable[[Host, int, int], float | None] + + @dataclass(frozen=True, kw_only=True) class ReolinkHostNumberEntityDescription( NumberEntityDescription, @@ -125,6 +139,7 @@ NUMBER_ENTITIES = ( cmd_key="GetPtzGuard", translation_key="guard_return_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=10, @@ -248,6 +263,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_face_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -264,6 +280,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_person_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -280,6 +297,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_vehicle_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -296,6 +314,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_package_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -312,6 +331,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_pet_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -330,6 +350,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_animal_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -346,6 +367,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAutoReply", translation_key="auto_quick_reply_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -385,6 +407,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiCfg", translation_key="auto_track_disappear_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -400,6 +423,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiCfg", translation_key="auto_track_stop_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -493,6 +517,168 @@ NUMBER_ENTITIES = ( ), ) +SMART_AI_NUMBER_ENTITIES = ( + ReolinkSmartAINumberEntityDescription( + key="crossline_sensitivity", + smart_type="crossline", + cmd_id=527, + translation_key="crossline_sensitivity", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_crossline"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "crossline", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "crossline", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="intrusion_sensitivity", + smart_type="intrusion", + cmd_id=529, + translation_key="intrusion_sensitivity", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_intrusion"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "intrusion", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "intrusion", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="linger_sensitivity", + smart_type="loitering", + cmd_id=531, + translation_key="linger_sensitivity", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_linger"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "loitering", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loitering", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="forgotten_item_sensitivity", + smart_type="legacy", + cmd_id=549, + translation_key="forgotten_item_sensitivity", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_forgotten_item"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "legacy", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "legacy", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="taken_item_sensitivity", + smart_type="loss", + cmd_id=551, + translation_key="taken_item_sensitivity", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_taken_item"), + value=lambda api, ch, loc: api.baichuan.smart_ai_sensitivity(ch, "loss", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loss", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="intrusion_delay", + smart_type="intrusion", + cmd_id=529, + translation_key="intrusion_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=10, + supported=lambda api, ch: api.supported(ch, "ai_intrusion"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "intrusion", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "intrusion", loc, delay=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="linger_delay", + smart_type="loitering", + cmd_id=531, + translation_key="linger_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=10, + supported=lambda api, ch: api.supported(ch, "ai_linger"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "loitering", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loitering", loc, delay=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="forgotten_item_delay", + smart_type="legacy", + cmd_id=549, + translation_key="forgotten_item_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=30, + supported=lambda api, ch: api.supported(ch, "ai_forgotten_item"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "legacy", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "legacy", loc, delay=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="taken_item_delay", + smart_type="loss", + cmd_id=551, + translation_key="taken_item_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=30, + supported=lambda api, ch: api.supported(ch, "ai_taken_item"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "loss", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loss", loc, delay=int(value) + ), + ), +) + HOST_NUMBER_ENTITIES = ( ReolinkHostNumberEntityDescription( key="alarm_volume", @@ -542,22 +728,32 @@ async def async_setup_entry( ) -> None: """Set up a Reolink number entities.""" reolink_data: ReolinkData = config_entry.runtime_data + api = reolink_data.host.api entities: list[NumberEntity] = [ ReolinkNumberEntity(reolink_data, channel, entity_description) for entity_description in NUMBER_ENTITIES - for channel in reolink_data.host.api.channels - if entity_description.supported(reolink_data.host.api, channel) + for channel in api.channels + if entity_description.supported(api, channel) ] + entities.extend( + ReolinkSmartAINumberEntity(reolink_data, channel, location, entity_description) + for entity_description in SMART_AI_NUMBER_ENTITIES + for channel in api.channels + for location in api.baichuan.smart_location_list( + channel, entity_description.smart_type + ) + if entity_description.supported(api, channel) + ) entities.extend( ReolinkHostNumberEntity(reolink_data, entity_description) for entity_description in HOST_NUMBER_ENTITIES - if entity_description.supported(reolink_data.host.api) + if entity_description.supported(api) ) entities.extend( ReolinkChimeNumberEntity(reolink_data, chime, entity_description) for entity_description in CHIME_NUMBER_ENTITIES - for chime in reolink_data.host.api.chime_list + for chime in api.chime_list ) async_add_entities(entities) @@ -599,6 +795,51 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): self.async_write_ha_state() +class ReolinkSmartAINumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): + """Base smart AI number entity class for Reolink IP cameras.""" + + entity_description: ReolinkSmartAINumberEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + location: int, + entity_description: ReolinkSmartAINumberEntityDescription, + ) -> None: + """Initialize Reolink number entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, channel) + + unique_index = self._host.api.baichuan.smart_ai_index( + channel, entity_description.smart_type, location + ) + self._attr_unique_id = f"{self._attr_unique_id}_{unique_index}" + + self._location = location + self._attr_mode = entity_description.mode + self._attr_translation_placeholders = { + "zone_name": self._host.api.baichuan.smart_ai_name( + channel, entity_description.smart_type, location + ) + } + + @property + def native_value(self) -> float | None: + """State of the number entity.""" + return self.entity_description.value( + self._host.api, self._channel, self._location + ) + + @raise_translated_error + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await self.entity_description.method( + self._host.api, self._channel, self._location, value + ) + self.async_write_ha_state() + + class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity): """Base number entity class for Reolink Host.""" diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 74823c4bd32..8bfea1c6910 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -15,7 +15,7 @@ "data_description": { "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.", "port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.", - "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", + "use_https": "Use an HTTPS (SSL) connection to the Reolink device.", "baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.", "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." @@ -66,7 +66,7 @@ "message": "Invalid input parameter: {err}" }, "api_error": { - "message": "The device responded with a error: {err}" + "message": "The device responded with an error: {err}" }, "invalid_content_type": { "message": "Received a different content type than expected: {err}" @@ -102,7 +102,13 @@ "message": "Error trying to update Reolink firmware: {err}" }, "config_entry_not_ready": { - "message": "Error while trying to setup {host}: {err}" + "message": "Error while trying to set up {host}: {err}" + }, + "update_already_running": { + "message": "Reolink firmware update already running, wait on completion before starting another" + }, + "firmware_rate_limit": { + "message": "Reolink firmware update server reached hourly rate limit: updating can be tried again in 1 hour" } }, "issues": { @@ -124,7 +130,7 @@ }, "firmware_update": { "title": "Reolink firmware update required", - "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})." + "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running an old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})." }, "hub_switch_deprecated": { "title": "Reolink Home Hub switches deprecated", @@ -562,6 +568,21 @@ "ai_animal_sensitivity": { "name": "AI animal sensitivity" }, + "crossline_sensitivity": { + "name": "AI crossline {zone_name} sensitivity" + }, + "intrusion_sensitivity": { + "name": "AI intrusion {zone_name} sensitivity" + }, + "linger_sensitivity": { + "name": "AI linger {zone_name} sensitivity" + }, + "forgotten_item_sensitivity": { + "name": "AI item forgotten {zone_name} sensitivity" + }, + "taken_item_sensitivity": { + "name": "AI item taken {zone_name} sensitivity" + }, "ai_face_delay": { "name": "AI face delay" }, @@ -580,6 +601,18 @@ "ai_animal_delay": { "name": "AI animal delay" }, + "intrusion_delay": { + "name": "AI intrusion {zone_name} delay" + }, + "linger_delay": { + "name": "AI linger {zone_name} delay" + }, + "forgotten_item_delay": { + "name": "AI item forgotten {zone_name} delay" + }, + "taken_item_delay": { + "name": "AI item taken {zone_name} delay" + }, "auto_quick_reply_time": { "name": "Auto quick reply time" }, @@ -809,8 +842,8 @@ "state": { "off": "[%key:common::state::off%]", "disarm": "Disarmed", - "home": "Home", - "away": "Away" + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]" } } }, @@ -860,7 +893,7 @@ }, "switch": { "ir_lights": { - "name": "Infra red lights in night mode" + "name": "Infrared lights in night mode" }, "record_audio": { "name": "Record audio" diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 0f106c0f2cc..af87a75eece 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -162,6 +162,7 @@ SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="manual_record", cmd_key="GetManualRec", + cmd_id=588, translation_key="manual_record", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "manual_record"), diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 0744d66fb5b..a7c883003b7 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -31,7 +31,7 @@ from .entity import ( ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) -from .util import ReolinkConfigEntry, ReolinkData +from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error PARALLEL_UPDATES = 0 RESUME_AFTER_INSTALL = 15 @@ -184,6 +184,7 @@ class ReolinkUpdateBaseEntity( f"## Release notes\n\n{new_firmware.release_notes}" ) + @raise_translated_error async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: @@ -196,6 +197,8 @@ class ReolinkUpdateBaseEntity( try: await self._host.api.update_firmware(self._channel) except ReolinkError as err: + if err.translation_key: + raise raise HomeAssistantError( translation_domain=DOMAIN, translation_key="firmware_install_error", diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index a5556b66a33..12b4825caeb 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -27,6 +27,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.storage import Store +from homeassistant.helpers.translation import async_get_exception_message from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -97,6 +98,30 @@ def get_device_uid_and_ch( return (device_uid, ch, is_chime) +def check_translation_key(err: ReolinkError) -> str | None: + """Check if the translation key from the upstream library is present.""" + if not err.translation_key: + return None + if async_get_exception_message(DOMAIN, err.translation_key) == err.translation_key: + # translation key not found in strings.json + return None + return err.translation_key + + +_EXCEPTION_TO_TRANSLATION_KEY = { + ApiError: "api_error", + InvalidContentTypeError: "invalid_content_type", + CredentialsInvalidError: "invalid_credentials", + LoginError: "login_error", + NoDataError: "no_data", + UnexpectedDataError: "unexpected_data", + NotSupportedError: "not_supported", + SubscriptionError: "subscription_error", + ReolinkConnectionError: "connection_error", + ReolinkTimeoutError: "timeout", +} + + # Decorators def raise_translated_error[**P, R]( func: Callable[P, Awaitable[R]], @@ -110,73 +135,14 @@ def raise_translated_error[**P, R]( except InvalidParameterError as err: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="invalid_parameter", - translation_placeholders={"err": str(err)}, - ) from err - except ApiError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="api_error", - translation_placeholders={"err": str(err)}, - ) from err - except InvalidContentTypeError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="invalid_content_type", - translation_placeholders={"err": str(err)}, - ) from err - except CredentialsInvalidError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="invalid_credentials", - translation_placeholders={"err": str(err)}, - ) from err - except LoginError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="login_error", - translation_placeholders={"err": str(err)}, - ) from err - except NoDataError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="no_data", - translation_placeholders={"err": str(err)}, - ) from err - except UnexpectedDataError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unexpected_data", - translation_placeholders={"err": str(err)}, - ) from err - except NotSupportedError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="not_supported", - translation_placeholders={"err": str(err)}, - ) from err - except SubscriptionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="subscription_error", - translation_placeholders={"err": str(err)}, - ) from err - except ReolinkConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - translation_placeholders={"err": str(err)}, - ) from err - except ReolinkTimeoutError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="timeout", + translation_key=check_translation_key(err) or "invalid_parameter", translation_placeholders={"err": str(err)}, ) from err except ReolinkError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="unexpected", + translation_key=check_translation_key(err) + or _EXCEPTION_TO_TRANSLATION_KEY.get(type(err), "unexpected"), translation_placeholders={"err": str(err)}, ) from err diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 027c39da70f..97d0b811509 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -236,7 +236,8 @@ SENSOR_TYPES = ( key="winddirection", name="Wind direction", icon="mdi:compass", - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, ), SensorEntityDescription( diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 4b256279445..6669b1367df 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -161,7 +161,8 @@ SENSOR_TYPES = ( RfxtrxSensorEntityDescription( key="Wind direction", translation_key="wind_direction", - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, ), RfxtrxSensorEntityDescription( diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index db4efad5bb4..d0a61540a53 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -105,15 +105,15 @@ "sound_15": "Sound 15", "down": "Down", "up": "Up", - "all_off": "All Off", - "all_on": "All On", + "all_off": "All off", + "all_on": "All on", "scene": "Scene", - "off": "Off", - "on": "On", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", "dim": "Dim", "bright": "Bright", - "all_group_off": "All/group Off", - "all_group_on": "All/group On", + "all_group_off": "All/group off", + "all_group_on": "All/group on", "chime": "Chime", "illegal_command": "Illegal command", "set_level": "Set level", diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index a3ccf0c6eed..81b412c6770 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -24,6 +24,7 @@ from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .coordinator import ( @@ -45,7 +46,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> entry.async_on_unload(entry.add_update_listener(update_listener)) user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) - api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL]) + api_client = RoborockApiClient( + entry.data[CONF_USERNAME], + entry.data[CONF_BASE_URL], + session=async_get_clientsession(hass), + ) _LOGGER.debug("Getting home data") try: home_data = await api_client.get_home_data_v2(user_data) @@ -159,6 +164,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> return True +async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: + """Migrate old configuration entries to the new format.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + entry.version, + entry.minor_version, + ) + if entry.version > 1: + # Downgrade from future version + return False + + # 1->2: Migrate from unique id as email address to unique id as rruid + if entry.minor_version == 1: + user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) + _LOGGER.debug("Updating unique id to %s", user_data.rruid) + hass.config_entries.async_update_entry( + entry, + unique_id=user_data.rruid, + version=1, + minor_version=2, + ) + + return True + + def build_setup_functions( hass: HomeAssistant, entry: RoborockConfigEntry, diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 1a6b67286bb..62943e0dcc9 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -21,14 +21,17 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) from homeassistant.const import CONF_USERNAME from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from . import RoborockConfigEntry from .const import ( CONF_BASE_URL, CONF_ENTRY_CODE, @@ -45,6 +48,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Roborock.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" @@ -59,11 +63,11 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: username = user_input[CONF_USERNAME] - await self.async_set_unique_id(username.lower()) - self._abort_if_unique_id_configured(error="already_configured_account") self._username = username _LOGGER.debug("Requesting code for Roborock account") - self._client = RoborockApiClient(username) + self._client = RoborockApiClient( + username, session=async_get_clientsession(self.hass) + ) errors = await self._request_code() if not errors: return await self.async_step_code() @@ -106,7 +110,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): code = user_input[CONF_ENTRY_CODE] _LOGGER.debug("Logging into Roborock account using email provided code") try: - login_data = await self._client.code_login(code) + user_data = await self._client.code_login(code) except RoborockInvalidCode: errors["base"] = "invalid_code" except RoborockException: @@ -116,17 +120,20 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + await self.async_set_unique_id(user_data.rruid) if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") reauth_entry = self._get_reauth_entry() self.hass.config_entries.async_update_entry( reauth_entry, data={ **reauth_entry.data, - CONF_USER_DATA: login_data.as_dict(), + CONF_USER_DATA: user_data.as_dict(), }, ) return self.async_abort(reason="reauth_successful") - return self._create_entry(self._client, self._username, login_data) + self._abort_if_unique_id_configured(error="already_configured_account") + return self._create_entry(self._client, self._username, user_data) return self.async_show_form( step_id="code", @@ -134,13 +141,32 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow started by a dhcp discovery.""" + await self._async_handle_discovery_without_unique_id() + device_registry = dr.async_get(self.hass) + device = device_registry.async_get_device( + connections={ + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(discovery_info.macaddress)) + } + ) + if device is not None and any( + identifier[0] == DOMAIN for identifier in device.identifiers + ): + return self.async_abort(reason="already_configured") + return await self.async_step_user() + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Perform reauth upon an API authentication error.""" self._username = entry_data[CONF_USERNAME] assert self._username - self._client = RoborockApiClient(self._username) + self._client = RoborockApiClient( + self._username, session=async_get_clientsession(self.hass) + ) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -170,7 +196,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RoborockConfigEntry, ) -> RoborockOptionsFlowHandler: """Create the options flow.""" return RoborockOptionsFlowHandler(config_entry) @@ -179,7 +205,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): class RoborockOptionsFlowHandler(OptionsFlow): """Handle an option flow for Roborock.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: RoborockConfigEntry) -> None: """Initialize options flow.""" self.options = deepcopy(dict(config_entry.options)) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 6d0c9737a29..2439a4f904a 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -129,7 +129,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.current_map: int | None = None if mac := self.roborock_device_info.network_info.mac: - self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)} + self.device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)) + } # Maps from map flag to map name self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} @@ -151,6 +153,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ImageConfig(scale=MAP_SCALE), [], ) + self.last_update_state: str | None = None @cached_property def dock_device_info(self) -> DeviceInfo: @@ -223,7 +226,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Update the currently selected map.""" # The current map was set in the props update, so these can be done without # worry of applying them to the wrong map. - if self.current_map is None: + if self.current_map is None or self.current_map not in self.maps: # This exists as a safeguard/ to keep mypy happy. return try: @@ -289,7 +292,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): async def _async_update_data(self) -> DeviceProp: """Update data via library.""" - previous_state = self.roborock_device_info.props.status.state_name try: # Update device props and standard api information await self._update_device_prop() @@ -300,13 +302,17 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL # since the last map update, you can update the map. new_status = self.roborock_device_info.props.status - if self.current_map is not None and ( - ( - new_status.in_cleaning - and (dt_util.utcnow() - self.maps[self.current_map].last_updated) - > IMAGE_CACHE_INTERVAL + if ( + self.current_map is not None + and (current_map := self.maps.get(self.current_map)) + and ( + ( + new_status.in_cleaning + and (dt_util.utcnow() - current_map.last_updated) + > IMAGE_CACHE_INTERVAL + ) + or self.last_update_state != new_status.state_name ) - or previous_state != new_status.state_name ): try: await self.update_map() @@ -328,6 +334,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL else: self.update_interval = V1_LOCAL_NOT_CLEANING_INTERVAL + self.last_update_state = self.roborock_device_info.props.status.state_name return self.roborock_device_info.props def _set_current_map(self) -> None: diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 45cfe4e12d8..531590d5d6e 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -3,11 +3,23 @@ "name": "Roborock", "codeowners": ["@Lash-L", "@allenporter"], "config_flow": true, + "dhcp": [ + { + "macaddress": "249E7D*" + }, + { + "macaddress": "B04A39*" + }, + { + "hostname": "roborock-*" + } + ], "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], + "quality_scale": "silver", "requirements": [ - "python-roborock==2.14.0", + "python-roborock==2.16.1", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index 06a7638c222..32ddb145f90 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -21,7 +21,7 @@ rules: test-before-setup: done unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done @@ -29,14 +29,12 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold devices: done diagnostics: done - discovery: - status: todo - comment: Determine if these devices can support discovery + discovery: done discovery-update-info: status: exempt comment: Devices do not support discovery. @@ -73,7 +71,5 @@ rules: stale-devices: done # Platinum async-dependency: todo - inject-websession: - status: todo - comment: Web API uses aiohttp but does not yet inject web session. - strict-typing: todo + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index c79bf817d09..208020dccab 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -149,8 +149,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """Set the option.""" for map_id, map_ in self.coordinator.maps.items(): if map_.name == option: - await self.send( + await self._send_command( RoborockCommand.LOAD_MULTI_MAP, + self.api, [map_id], ) # Update the current map id manually so that nothing gets broken @@ -159,6 +160,7 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): # We need to wait after updating the map # so that other commands will be executed correctly. await asyncio.sleep(MAP_SLEEP) + await self.coordinator.async_refresh() break @property diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 33ecaf74d4f..a007d6fa457 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -381,7 +381,10 @@ class RoborockCurrentRoom(RoborockCoordinatedEntityV1, SensorEntity): @property def options(self) -> list[str]: """Return the currently valid rooms.""" - if self.coordinator.current_map is not None: + if ( + self.coordinator.current_map is not None + and self.coordinator.current_map in self.coordinator.maps + ): return list( self.coordinator.maps[self.coordinator.current_map].rooms.values() ) @@ -390,7 +393,10 @@ class RoborockCurrentRoom(RoborockCoordinatedEntityV1, SensorEntity): @property def native_value(self) -> str | None: """Return the value reported by the sensor.""" - if self.coordinator.current_map is not None: + if ( + self.coordinator.current_map is not None + and self.coordinator.current_map in self.coordinator.maps + ): return self.coordinator.maps[self.coordinator.current_map].current_room return None diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index caad67e4ce6..d27f4064170 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -35,7 +35,8 @@ }, "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "Wrong account: Please authenticate with the right account." } }, "options": { @@ -338,7 +339,7 @@ "zeo_state": { "name": "State", "state": { - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "weighing": "Weighing", "soaking": "Soaking", "washing": "Washing", @@ -367,12 +368,12 @@ "name": "Mop intensity", "state": { "off": "[%key:common::state::off%]", - "low": "Low", + "low": "[%key:common::state::low%]", "mild": "Mild", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "moderate": "Moderate", "max": "Max", - "high": "High", + "high": "[%key:common::state::high%]", "intense": "Intense", "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]", "custom_water_flow": "Custom water flow", @@ -432,7 +433,7 @@ "off": "[%key:common::state::off%]", "max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]", "max_plus": "Max plus", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "quiet": "Quiet", "silent": "Silent", "standard": "[%key:component::roborock::entity::select::mop_mode::state::standard%]", diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 04348bc3bfb..62f1f8b1736 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -47,7 +47,7 @@ "name": "Supports AirPlay" }, "supports_ethernet": { - "name": "Supports ethernet" + "name": "Supports Ethernet" }, "supports_find_remote": { "name": "Supports find remote" diff --git a/homeassistant/components/romy/strings.json b/homeassistant/components/romy/strings.json index 78721da17ba..b8725624ac7 100644 --- a/homeassistant/components/romy/strings.json +++ b/homeassistant/components/romy/strings.json @@ -36,11 +36,11 @@ "fan_speed": { "state": { "default": "Default", - "normal": "Normal", - "silent": "Silent", + "normal": "[%key:common::state::normal%]", + "high": "[%key:common::state::high%]", "intensive": "Intensive", + "silent": "Silent", "super_silent": "Super silent", - "high": "High", "auto": "Auto" } } diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json index 978c916e3ee..8c21b856b80 100644 --- a/homeassistant/components/route53/manifest.json +++ b/homeassistant/components/route53/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], "quality_scale": "legacy", - "requirements": ["boto3==1.34.131"] + "requirements": ["boto3==1.37.1"] } diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index f91406e8a4b..acedbaf0573 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.4.0"], + "requirements": ["aiorussound==4.5.0"], "zeroconf": ["_rio._tcp.local."] } diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 6a30efd64f8..5bb69e7f121 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.7.2", "wakeonlan==2.1.0", - "async-upnp-client==0.43.0" + "async-upnp-client==0.44.0" ], "ssdp": [ { diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 4e6ecfd3593..1c475ee6c25 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -59,6 +59,9 @@ SUPPORT_SAMSUNGTV = ( # Max delay waiting for app_list to return, as some TVs simply ignore the request APP_LIST_DELAY = 3 +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index d6fef262d91..2c6b46c8bb2 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -13,6 +13,9 @@ from .const import LOGGER from .coordinator import SamsungTVConfigEntry from .entity import SamsungTVEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index c9d08f756d0..d08e2a843ba 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -9,7 +9,8 @@ "name": "[%key:common::config_flow::data::name%]" }, "data_description": { - "host": "The hostname or IP address of your TV." + "host": "The hostname or IP address of your TV.", + "name": "The name of your TV. This will be used to identify the device in Home Assistant." } }, "confirm": { @@ -22,10 +23,22 @@ "description": "After submitting, accept the popup on {device} requesting authorization within 30 seconds or input PIN." }, "encrypted_pairing": { - "description": "Please enter the PIN displayed on {device}." + "description": "Please enter the PIN displayed on {device}.", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The PIN displayed on your TV." + } }, "reauth_confirm_encrypted": { - "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]" + "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::samsungtv::config::step::encrypted_pairing::data_description::pin%]" + } } }, "error": { diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 56e72c2d2c0..42bd51de9d0 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -33,9 +33,9 @@ }, "select": { "auto_lock_time": { - "name": "Auto-Lock time", + "name": "Auto-lock time", "state": { - "0": "Disabled", + "0": "[%key:common::state::disabled%]", "15": "15 seconds", "30": "30 seconds", "60": "1 minute", diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index e6398c5076e..610695aaf7b 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -14,5 +14,6 @@ }, "iot_class": "cloud_polling", "loggers": ["pysensibo"], + "quality_scale": "platinum", "requirements": ["pysensibo==1.1.0"] } diff --git a/homeassistant/components/sensibo/quality_scale.yaml b/homeassistant/components/sensibo/quality_scale.yaml index c21cf100e9d..3d71d0ad3ba 100644 --- a/homeassistant/components/sensibo/quality_scale.yaml +++ b/homeassistant/components/sensibo/quality_scale.yaml @@ -19,9 +19,9 @@ rules: comment: | No integrations services. common-modules: done - docs-high-level-description: todo + docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done docs-actions: done brands: done # Silver @@ -39,9 +39,7 @@ rules: comment: | Tests are very complex and needs a rewrite for future additions integration-owner: done - docs-installation-parameters: - status: todo - comment: configuration_basic + docs-installation-parameters: done docs-configuration-parameters: status: exempt comment: | @@ -71,13 +69,13 @@ rules: status: exempt comment: | This integration doesn't have any cases where raising an issue is needed. - docs-use-cases: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-data-update: todo - docs-known-limitations: todo - docs-troubleshooting: todo - docs-examples: todo + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done # Platinum async-dependency: done diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 6aba2be52fc..0fbcda461c8 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -330,7 +330,7 @@ "timer_on_switch": { "name": "Timer", "state_attributes": { - "id": { "name": "Id" }, + "id": { "name": "ID" }, "turn_on": { "name": "Turns on", "state": { @@ -594,7 +594,7 @@ "issues": { "deprecated_entity_horizontalswing": { "title": "The Sensibo {name} entity is deprecated", - "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\n, Disable the `{entity}` and reload the config entry or restart Home Assistant to fix this issue." + "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\nDisable `{entity}` and reload the config entry or restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index e3ee566a855..e06ee85cd03 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -44,6 +44,7 @@ from .const import ( # noqa: F401 DEVICE_CLASSES_SCHEMA, DOMAIN, NON_NUMERIC_DEVICE_CLASSES, + STATE_CLASS_UNITS, STATE_CLASSES, STATE_CLASSES_SCHEMA, UNIT_CONVERTERS, @@ -713,6 +714,18 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): report_issue, ) + # Validate unit of measurement used for sensors with a state class + if ( + state_class + and (units := STATE_CLASS_UNITS.get(state_class)) is not None + and native_unit_of_measurement not in units + ): + raise ValueError( + f"Sensor {self.entity_id} ({type(self)}) is using native unit of " + f"measurement '{native_unit_of_measurement}' which is not a valid unit " + f"for the state class ('{state_class}') it is using; expected one of {units};" + ) + return value def _display_precision_or_none(self) -> int | None: diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index e1f7dd13d93..63af8e5bf52 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -491,6 +491,9 @@ class SensorStateClass(StrEnum): MEASUREMENT = "measurement" """The state represents a measurement in present time.""" + MEASUREMENT_ANGLE = "measurement_angle" + """The state represents a angle measurement in present time. Currently only degrees are supported.""" + TOTAL = "total" """The state represents a total amount. @@ -693,6 +696,11 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, - SensorDeviceClass.WIND_DIRECTION: set(), + SensorDeviceClass.WIND_DIRECTION: {SensorStateClass.MEASUREMENT_ANGLE}, SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT}, } + + +STATE_CLASS_UNITS: dict[SensorStateClass | str, set[type[StrEnum] | str | None]] = { + SensorStateClass.MEASUREMENT_ANGLE: {DEGREE}, +} diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 4e8e27e0c79..c321caa616d 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Callable, Iterable from contextlib import suppress +from dataclasses import dataclass import datetime import itertools import logging @@ -21,6 +22,7 @@ from homeassistant.components.recorder import ( ) from homeassistant.components.recorder.models import ( StatisticData, + StatisticMeanType, StatisticMetaData, StatisticResult, ) @@ -52,10 +54,22 @@ from .const import ( _LOGGER = logging.getLogger(__name__) + +@dataclass +class _StatisticsConfig: + types: set[str] + mean_type: StatisticMeanType = StatisticMeanType.NONE + + DEFAULT_STATISTICS = { - SensorStateClass.MEASUREMENT: {"mean", "min", "max"}, - SensorStateClass.TOTAL: {"sum"}, - SensorStateClass.TOTAL_INCREASING: {"sum"}, + SensorStateClass.MEASUREMENT: _StatisticsConfig( + {"mean", "min", "max"}, StatisticMeanType.ARITHMETIC + ), + SensorStateClass.MEASUREMENT_ANGLE: _StatisticsConfig( + {"mean"}, StatisticMeanType.CIRCULAR + ), + SensorStateClass.TOTAL: _StatisticsConfig({"sum"}), + SensorStateClass.TOTAL_INCREASING: _StatisticsConfig({"sum"}), } EQUIVALENT_UNITS = { @@ -76,8 +90,15 @@ WARN_NEGATIVE: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_total_increasing_nega # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unsupported_unit") WARN_UNSTABLE_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unstable_unit") +# Keep track of entities for which a warning about statistics mean algorithm change has been logged +WARN_STATISTICS_MEAN_CHANGED: HassKey[set[str]] = HassKey( + f"{DOMAIN}_warn_statistics_mean_change" +) # Link to dev statistics where issues around LTS can be fixed LINK_DEV_STATISTICS = "https://my.home-assistant.io/redirect/developer_statistics" +STATE_CLASS_REMOVED_ISSUE = "state_class_removed" +UNITS_CHANGED_ISSUE = "units_changed" +MEAN_TYPE_CHANGED_ISSUE = "mean_type_changed" def _get_sensor_states(hass: HomeAssistant) -> list[State]: @@ -99,7 +120,7 @@ def _get_sensor_states(hass: HomeAssistant) -> list[State]: ] -def _time_weighted_average( +def _time_weighted_arithmetic_mean( fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime ) -> float: """Calculate a time weighted average. @@ -137,6 +158,43 @@ def _time_weighted_average( return accumulated / (end - start).total_seconds() +def _time_weighted_circular_mean( + fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime +) -> tuple[float, float]: + """Calculate a time weighted circular mean. + + The circular mean is calculated by weighting the states by duration in seconds between + state changes. + Note: there's no interpolation of values between state changes. + """ + old_fstate: float | None = None + old_start_time: datetime.datetime | None = None + values: list[tuple[float, float]] = [] + + for fstate, state in fstates: + # The recorder will give us the last known state, which may be well + # before the requested start time for the statistics + start_time = max(state.last_updated, start) + if old_start_time is None: + # Adjust start time, if there was no last known state + start = start_time + else: + duration = (start_time - old_start_time).total_seconds() + assert old_fstate is not None + values.append((old_fstate, duration)) + + old_fstate = fstate + old_start_time = start_time + + if old_fstate is not None: + # Add last value weighted by duration until end of the period + assert old_start_time is not None + duration = (end - old_start_time).total_seconds() + values.append((old_fstate, duration)) + + return statistics.weighted_circular_mean(values) + + def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: """Return a set of all units.""" return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates} @@ -362,7 +420,7 @@ def reset_detected( return fstate < 0.9 * previous_fstate -def _wanted_statistics(sensor_states: list[State]) -> dict[str, set[str]]: +def _wanted_statistics(sensor_states: list[State]) -> dict[str, _StatisticsConfig]: """Prepare a dict with wanted statistics for entities.""" return { state.entity_id: DEFAULT_STATISTICS[state.attributes[ATTR_STATE_CLASS]] @@ -406,7 +464,9 @@ def compile_statistics( # noqa: C901 wanted_statistics = _wanted_statistics(sensor_states) # Get history between start and end entities_full_history = [ - i.entity_id for i in sensor_states if "sum" in wanted_statistics[i.entity_id] + i.entity_id + for i in sensor_states + if "sum" in wanted_statistics[i.entity_id].types ] history_list: dict[str, list[State]] = {} if entities_full_history: @@ -421,7 +481,7 @@ def compile_statistics( # noqa: C901 entities_significant_history = [ i.entity_id for i in sensor_states - if "sum" not in wanted_statistics[i.entity_id] + if "sum" not in wanted_statistics[i.entity_id].types ] if entities_significant_history: _history_list = history.get_full_significant_states_with_session( @@ -471,7 +531,7 @@ def compile_statistics( # noqa: C901 continue state_class: str = _state.attributes[ATTR_STATE_CLASS] to_process.append((entity_id, statistics_unit, state_class, valid_float_states)) - if "sum" in wanted_statistics[entity_id]: + if "sum" in wanted_statistics[entity_id].types: to_query.add(entity_id) last_stats = statistics.get_latest_short_term_statistics_with_session( @@ -483,6 +543,10 @@ def compile_statistics( # noqa: C901 state_class, valid_float_states, ) in to_process: + mean_type = StatisticMeanType.NONE + if "mean" in wanted_statistics[entity_id].types: + mean_type = wanted_statistics[entity_id].mean_type + # Check metadata if old_metadata := old_metadatas.get(entity_id): if not _equivalent_units( @@ -508,10 +572,34 @@ def compile_statistics( # noqa: C901 ) continue + if ( + mean_type is not StatisticMeanType.NONE + and (old_mean_type := old_metadata[1]["mean_type"]) + is not StatisticMeanType.NONE + and mean_type != old_mean_type + ): + if WARN_STATISTICS_MEAN_CHANGED not in hass.data: + hass.data[WARN_STATISTICS_MEAN_CHANGED] = set() + if entity_id not in hass.data[WARN_STATISTICS_MEAN_CHANGED]: + hass.data[WARN_STATISTICS_MEAN_CHANGED].add(entity_id) + _LOGGER.warning( + ( + "The statistics mean algorithm for %s have changed from %s to %s." + " Generation of long term statistics will be suppressed" + " unless it changes back or go to %s to delete the old" + " statistics" + ), + entity_id, + old_mean_type.name, + mean_type.name, + LINK_DEV_STATISTICS, + ) + continue + # Set meta data meta: StatisticMetaData = { - "has_mean": "mean" in wanted_statistics[entity_id], - "has_sum": "sum" in wanted_statistics[entity_id], + "mean_type": mean_type, + "has_sum": "sum" in wanted_statistics[entity_id].types, "name": None, "source": RECORDER_DOMAIN, "statistic_id": entity_id, @@ -520,19 +608,26 @@ def compile_statistics( # noqa: C901 # Make calculations stat: StatisticData = {"start": start} - if "max" in wanted_statistics[entity_id]: + if "max" in wanted_statistics[entity_id].types: stat["max"] = max( *itertools.islice(zip(*valid_float_states, strict=False), 1) ) - if "min" in wanted_statistics[entity_id]: + if "min" in wanted_statistics[entity_id].types: stat["min"] = min( *itertools.islice(zip(*valid_float_states, strict=False), 1) ) - if "mean" in wanted_statistics[entity_id]: - stat["mean"] = _time_weighted_average(valid_float_states, start, end) + match mean_type: + case StatisticMeanType.ARITHMETIC: + stat["mean"] = _time_weighted_arithmetic_mean( + valid_float_states, start, end + ) + case StatisticMeanType.CIRCULAR: + stat["mean"], stat["mean_weight"] = _time_weighted_circular_mean( + valid_float_states, start, end + ) - if "sum" in wanted_statistics[entity_id]: + if "sum" in wanted_statistics[entity_id].types: last_reset = old_last_reset = None new_state = old_state = None _sum = 0.0 @@ -656,18 +751,25 @@ def list_statistic_ids( attributes = state.attributes state_class = attributes[ATTR_STATE_CLASS] provided_statistics = DEFAULT_STATISTICS[state_class] - if statistic_type is not None and statistic_type not in provided_statistics: + if ( + statistic_type is not None + and statistic_type not in provided_statistics.types + ): continue if ( - (has_sum := "sum" in provided_statistics) + (has_sum := "sum" in provided_statistics.types) and ATTR_LAST_RESET not in attributes and state_class == SensorStateClass.MEASUREMENT ): continue + mean_type = StatisticMeanType.NONE + if "mean" in provided_statistics.types: + mean_type = provided_statistics.mean_type + result[entity_id] = { - "has_mean": "mean" in provided_statistics, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": RECORDER_DOMAIN, @@ -697,7 +799,7 @@ def _update_issues( if numeric and state_class is None: # Sensor no longer has a valid state class report_issue( - "state_class_removed", + STATE_CLASS_REMOVED_ISSUE, entity_id, {"statistic_id": entity_id}, ) @@ -708,7 +810,7 @@ def _update_issues( if numeric and not _equivalent_units({state_unit, metadata_unit}): # The unit has changed, and it's not possible to convert report_issue( - "units_changed", + UNITS_CHANGED_ISSUE, entity_id, { "statistic_id": entity_id, @@ -722,7 +824,7 @@ def _update_issues( valid_units = (unit or "" for unit in converter.VALID_UNITS) valid_units_str = ", ".join(sorted(valid_units)) report_issue( - "units_changed", + UNITS_CHANGED_ISSUE, entity_id, { "statistic_id": entity_id, @@ -732,6 +834,23 @@ def _update_issues( }, ) + if ( + (metadata_mean_type := metadata[1]["mean_type"]) is not None + and state_class + and (state_mean_type := DEFAULT_STATISTICS[state_class].mean_type) + != metadata_mean_type + ): + # The mean type has changed and the old statistics are not valid anymore + report_issue( + MEAN_TYPE_CHANGED_ISSUE, + entity_id, + { + "statistic_id": entity_id, + "metadata_mean_type": metadata_mean_type, + "state_mean_type": state_mean_type, + }, + ) + def update_statistics_issues( hass: HomeAssistant, @@ -754,7 +873,11 @@ def update_statistics_issues( issue.domain != DOMAIN or not (issue_data := issue.data) or issue_data.get("issue_type") - not in ("state_class_removed", "units_changed") + not in ( + STATE_CLASS_REMOVED_ISSUE, + UNITS_CHANGED_ISSUE, + MEAN_TYPE_CHANGED_ISSUE, + ) ): continue issues.add(issue.issue_id) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index ae414a178e9..123c30da72e 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -278,10 +278,10 @@ "name": "Timestamp" }, "volatile_organic_compounds": { - "name": "VOCs" + "name": "Volatile organic compounds" }, "volatile_organic_compounds_parts": { - "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + "name": "Volatile organic compounds parts" }, "voltage": { "name": "Voltage" @@ -309,6 +309,10 @@ } }, "issues": { + "mean_type_changed": { + "title": "The mean type of {statistic_id} has changed", + "description": "" + }, "state_class_removed": { "title": "{statistic_id} no longer has a state class", "description": "" diff --git a/homeassistant/components/sensorpush_cloud/manifest.json b/homeassistant/components/sensorpush_cloud/manifest.json index ad817251fa1..6fd6513ad2d 100644 --- a/homeassistant/components/sensorpush_cloud/manifest.json +++ b/homeassistant/components/sensorpush_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["sensorpush_api", "sensorpush_ha"], "quality_scale": "bronze", - "requirements": ["sensorpush-api==2.1.1", "sensorpush-ha==1.3.2"] + "requirements": ["sensorpush-api==2.1.2", "sensorpush-ha==1.3.2"] } diff --git a/homeassistant/components/sentry/strings.json b/homeassistant/components/sentry/strings.json index efcdb631f3c..22f7b355e0e 100644 --- a/homeassistant/components/sentry/strings.json +++ b/homeassistant/components/sentry/strings.json @@ -24,7 +24,7 @@ "event_handled": "Send handled events", "event_third_party_packages": "Send events from third-party packages", "logging_event_level": "The log level Sentry will register an event for", - "logging_level": "The log level Sentry will record logs as breadcrums for", + "logging_level": "The log level Sentry will record events as breadcrumbs for", "tracing": "Enable performance tracing", "tracing_sample_rate": "Tracing sample rate; between 0.0 and 1.0 (1.0 = 100%)" } diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json index cfe9196f596..2a5d3c78737 100644 --- a/homeassistant/components/serial/manifest.json +++ b/homeassistant/components/serial/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/serial", "iot_class": "local_polling", - "requirements": ["pyserial-asyncio-fast==0.14"] + "requirements": ["pyserial-asyncio-fast==0.16"] } diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index 3c4c98db38f..33826baaf5b 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -3,7 +3,7 @@ "flow_title": "Add Shark IQ account", "step": { "user": { - "description": "Sign into your SharkClean account to control your devices.", + "description": "Sign in to your SharkClean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 7440013940c..ee28c41f18b 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Final +from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions from aioshelly.const import DEFAULT_COAP_PORT, RPC_GENERATIONS @@ -11,6 +12,7 @@ from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, MacAddressMismatchError, + RpcCallError, ) from aioshelly.rpc_device import RpcDevice, bluetooth_mac_from_primary_mac import voluptuous as vol @@ -59,6 +61,7 @@ from .utils import ( get_coap_context, get_device_entry_gen, get_http_port, + get_rpc_scripts_event_types, get_ws_context, ) @@ -108,6 +111,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool: """Set up Shelly from a config entry.""" + entry.runtime_data = ShellyEntryData([]) + # The custom component for Shelly devices uses shelly domain as well as core # integration. If the user removes the custom component but doesn't remove the # config entry, core integration will try to configure that config entry with an @@ -159,7 +164,8 @@ async def _async_setup_block_entry( device_entry = None sleep_period = entry.data.get(CONF_SLEEP_PERIOD) - runtime_data = entry.runtime_data = ShellyEntryData(BLOCK_SLEEPING_PLATFORMS) + runtime_data = entry.runtime_data + runtime_data.platforms = BLOCK_SLEEPING_PLATFORMS # Some old firmware have a wrong sleep period hardcoded value. # Following code block will force the right value for affected devices @@ -186,13 +192,25 @@ async def _async_setup_block_entry( if not device.firmware_supported: async_create_issue_unsupported_firmware(hass, entry) await device.shutdown() - raise ConfigEntryNotReady + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="firmware_unsupported", + translation_placeholders={"device": entry.title}, + ) except (DeviceConnectionError, MacAddressMismatchError) as err: await device.shutdown() - raise ConfigEntryNotReady(repr(err)) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err except InvalidAuthError as err: await device.shutdown() - raise ConfigEntryAuthFailed(repr(err)) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"device": entry.title}, + ) from err runtime_data.block = ShellyBlockCoordinator(hass, entry, device) runtime_data.block.async_setup() @@ -258,7 +276,8 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) device_entry = None sleep_period = entry.data.get(CONF_SLEEP_PERIOD) - runtime_data = entry.runtime_data = ShellyEntryData(RPC_SLEEPING_PLATFORMS) + runtime_data = entry.runtime_data + runtime_data.platforms = RPC_SLEEPING_PLATFORMS if sleep_period == 0: # Not a sleeping device, finish setup @@ -269,13 +288,30 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) if not device.firmware_supported: async_create_issue_unsupported_firmware(hass, entry) await device.shutdown() - raise ConfigEntryNotReady - except (DeviceConnectionError, MacAddressMismatchError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="firmware_unsupported", + translation_placeholders={"device": entry.title}, + ) + runtime_data.rpc_supports_scripts = await device.supports_scripts() + if runtime_data.rpc_supports_scripts: + runtime_data.rpc_script_events = await get_rpc_scripts_event_types( + device, ignore_scripts=[BLE_SCRIPT_NAME] + ) + except (DeviceConnectionError, MacAddressMismatchError, RpcCallError) as err: await device.shutdown() - raise ConfigEntryNotReady(repr(err)) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err except InvalidAuthError as err: await device.shutdown() - raise ConfigEntryAuthFailed(repr(err)) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"device": entry.title}, + ) from err runtime_data.rpc = ShellyRpcCoordinator(hass, entry, device) runtime_data.rpc.async_setup() diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 1f3c555a64b..06dffba5ead 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Callable from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any, Final -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY, RPC_GENERATIONS +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.button import ( ButtonDeviceClass, @@ -16,15 +17,20 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -from .const import LOGGER, SHELLY_GAS_MODELS +from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .utils import get_device_entry_gen +from .utils import get_device_entry_gen, get_rpc_key_ids @dataclass(frozen=True, kw_only=True) @@ -33,7 +39,7 @@ class ShellyButtonDescription[ ](ButtonEntityDescription): """Class to describe a Button entity.""" - press_action: Callable[[_ShellyCoordinatorT], Coroutine[Any, Any, None]] + press_action: str supported: Callable[[_ShellyCoordinatorT], bool] = lambda _: True @@ -44,14 +50,14 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_reboot(), + press_action="trigger_reboot", ), ShellyButtonDescription[ShellyBlockCoordinator]( key="self_test", name="Self test", translation_key="self_test", entity_category=EntityCategory.DIAGNOSTIC, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_self_test(), + press_action="trigger_shelly_gas_self_test", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( @@ -59,7 +65,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Mute", translation_key="mute", entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_mute(), + press_action="trigger_shelly_gas_mute", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( @@ -67,11 +73,22 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Unmute", translation_key="unmute", entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_unmute(), + press_action="trigger_shelly_gas_unmute", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ] +BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [ + ShellyButtonDescription[ShellyRpcCoordinator]( + key="calibrate", + name="Calibrate", + translation_key="calibrate", + entity_category=EntityCategory.CONFIG, + press_action="trigger_blu_trv_calibration", + supported=lambda coordinator: coordinator.device.model == MODEL_BLU_GATEWAY, + ), +] + @callback def async_migrate_unique_ids( @@ -123,14 +140,28 @@ async def async_setup_entry( hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) ) - async_add_entities( + entities: list[ShellyButton | ShellyBluTrvButton] = [] + + entities.extend( ShellyButton(coordinator, button) for button in BUTTONS if button.supported(coordinator) ) + if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER): + if TYPE_CHECKING: + assert isinstance(coordinator, ShellyRpcCoordinator) -class ShellyButton( + entities.extend( + ShellyBluTrvButton(coordinator, button, id_) + for id_ in blutrv_key_ids + for button in BLU_TRV_BUTTONS + ) + + async_add_entities(entities) + + +class ShellyBaseButton( CoordinatorEntity[ShellyRpcCoordinator | ShellyBlockCoordinator], ButtonEntity ): """Defines a Shelly base button.""" @@ -148,14 +179,98 @@ class ShellyButton( ) -> None: """Initialize Shelly button.""" super().__init__(coordinator) + self.entity_description = description + async def async_press(self) -> None: + """Triggers the Shelly button press service.""" + try: + await self._press_method() + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except InvalidAuthError: + await self.coordinator.async_shutdown_device_and_start_reauth() + + async def _press_method(self) -> None: + """Press method.""" + raise NotImplementedError + + +class ShellyButton(ShellyBaseButton): + """Defines a Shelly button.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator, + description: ShellyButtonDescription[ + ShellyRpcCoordinator | ShellyBlockCoordinator + ], + ) -> None: + """Initialize Shelly button.""" + super().__init__(coordinator, description) + self._attr_name = f"{coordinator.device.name} {description.name}" self._attr_unique_id = f"{coordinator.mac}_{description.key}" self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) - async def async_press(self) -> None: - """Triggers the Shelly button press service.""" - await self.entity_description.press_action(self.coordinator) + async def _press_method(self) -> None: + """Press method.""" + method = getattr(self.coordinator.device, self.entity_description.press_action) + + if TYPE_CHECKING: + assert method is not None + + await method() + + +class ShellyBluTrvButton(ShellyBaseButton): + """Represent a Shelly BLU TRV button.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + description: ShellyButtonDescription, + id_: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator, description) + + ble_addr: str = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["addr"] + device_name = ( + coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["name"] + or f"shellyblutrv-{ble_addr.replace(':', '')}" + ) + self._attr_name = f"{device_name} {description.name}" + self._attr_unique_id = f"{ble_addr}_{description.key}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) + self._id = id_ + + async def _press_method(self) -> None: + """Press method.""" + method = getattr(self.coordinator.device, self.entity_description.press_action) + + if TYPE_CHECKING: + assert method is not None + + await method(self._id) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index a3ec9be7cb0..498f2d3dba9 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -7,7 +7,12 @@ from dataclasses import asdict, dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_NAME, RPC_GENERATIONS +from aioshelly.const import ( + BLU_TRV_IDENTIFIER, + BLU_TRV_MODEL_NAME, + BLU_TRV_TIMEOUT, + RPC_GENERATIONS, +) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -36,7 +41,6 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( BLU_TRV_TEMPERATURE_SETTINGS, - BLU_TRV_TIMEOUT, DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, @@ -322,8 +326,12 @@ class BlockSleepingClimate( except DeviceConnectionError as err: self.coordinator.last_update_success = False raise HomeAssistantError( - f"Setting state for entity {self.name} failed, state: {kwargs}, error:" - f" {err!r}" + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 8e47235c981..200a88ea24c 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -7,12 +7,7 @@ from typing import Any, Final from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info -from aioshelly.const import ( - BLOCK_GENERATIONS, - DEFAULT_HTTP_PORT, - MODEL_WALL_DISPLAY, - RPC_GENERATIONS, -) +from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, @@ -22,12 +17,7 @@ from aioshelly.exceptions import ( from aioshelly.rpc_device import RpcDevice import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -49,7 +39,7 @@ from .const import ( LOGGER, BLEScannerMode, ) -from .coordinator import async_reconnect_soon +from .coordinator import ShellyConfigEntry, async_reconnect_soon from .utils import ( get_block_device_sleep_period, get_coap_context, @@ -458,19 +448,17 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + def async_get_options_flow(config_entry: ShellyConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @classmethod @callback - def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: + def async_supports_options_flow(cls, config_entry: ShellyConfigEntry) -> bool: """Return options flow support for this handler.""" - return ( - get_device_entry_gen(config_entry) in RPC_GENERATIONS - and not config_entry.data.get(CONF_SLEEP_PERIOD) - and config_entry.data.get(CONF_MODEL) != MODEL_WALL_DISPLAY - ) + return get_device_entry_gen( + config_entry + ) in RPC_GENERATIONS and not config_entry.data.get(CONF_SLEEP_PERIOD) class OptionsFlowHandler(OptionsFlow): @@ -480,6 +468,13 @@ class OptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle options flow.""" + if ( + supports_scripts := self.config_entry.runtime_data.rpc_supports_scripts + ) is None: + return self.async_abort(reason="cannot_connect") + if not supports_scripts: + return self.async_abort(reason="no_scripts_support") + if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index d47f2b0ae80..43fb6df18d0 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -25,6 +25,7 @@ from aioshelly.const import ( MODEL_VALVE, MODEL_VINTAGE_V2, MODEL_WALL_DISPLAY, + MODEL_WALL_DISPLAY_X2, ) from homeassistant.components.number import NumberMode @@ -245,6 +246,7 @@ GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/" GEN2_BETA_RELEASE_URL = f"{GEN2_RELEASE_URL}#unreleased" DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( MODEL_WALL_DISPLAY, + MODEL_WALL_DISPLAY_X2, MODEL_MOTION, MODEL_MOTION_2, MODEL_VALVE, @@ -271,9 +273,6 @@ API_WS_URL = "/api/shelly/ws" COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+") -# value confirmed by Shelly team -BLU_TRV_TIMEOUT = 60 - ROLE_TO_DEVICE_CLASS_MAP = { "current_humidity": SensorDeviceClass.HUMIDITY, "current_temperature": SensorDeviceClass.TEMPERATURE, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 95812c12e10..4a1ea72f38a 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -88,6 +88,8 @@ class ShellyEntryData: rest: ShellyRestCoordinator | None = None rpc: ShellyRpcCoordinator | None = None rpc_poll: ShellyRpcPollingCoordinator | None = None + rpc_script_events: dict[int, list[str]] | None = None + rpc_supports_scripts: bool | None = None type ShellyConfigEntry = ConfigEntry[ShellyEntryData] @@ -377,14 +379,23 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): if self.sleep_period: # Sleeping device, no point polling it, just mark it unavailable raise UpdateFailed( - f"Sleeping device did not update within {self.sleep_period} seconds interval" + translation_domain=DOMAIN, + translation_key="update_error_sleeping_device", + translation_placeholders={ + "device": self.name, + "period": str(self.sleep_period), + }, ) LOGGER.debug("Polling Shelly Block Device - %s", self.name) try: await self.device.update() except DeviceConnectionError as err: - raise UpdateFailed(repr(err)) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.name}, + ) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() @@ -469,7 +480,11 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]): return await self.device.update_shelly() except (DeviceConnectionError, MacAddressMismatchError) as err: - raise UpdateFailed(repr(err)) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.name}, + ) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() else: @@ -635,7 +650,12 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if self.sleep_period: # Sleeping device, no point polling it, just mark it unavailable raise UpdateFailed( - f"Sleeping device did not update within {self.sleep_period} seconds interval" + translation_domain=DOMAIN, + translation_key="update_error_sleeping_device", + translation_placeholders={ + "device": self.name, + "period": str(self.sleep_period), + }, ) async with self._connection_lock: @@ -643,7 +663,11 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): return if not await self._async_device_connect_task(): - raise UpdateFailed("Device reconnect error") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error_reconnect_error", + translation_placeholders={"device": self.name}, + ) async def _async_disconnected(self, reconnect: bool) -> None: """Handle device disconnected.""" @@ -693,7 +717,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): is updated. """ if not self.sleep_period: - await self._async_connect_ble_scanner() + if self.config_entry.runtime_data.rpc_supports_scripts: + await self._async_connect_ble_scanner() else: await self._async_setup_outbound_websocket() @@ -819,13 +844,21 @@ class ShellyRpcPollingCoordinator(ShellyCoordinatorBase[RpcDevice]): async def _async_update_data(self) -> None: """Fetch data.""" if not self.device.connected: - raise UpdateFailed("Device disconnected") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error_device_disconnected", + translation_placeholders={"device": self.name}, + ) LOGGER.debug("Polling Shelly RPC Device - %s", self.name) try: await self.device.poll() except (DeviceConnectionError, RpcCallError) as err: - raise UpdateFailed(f"Device disconnected: {err!r}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.name}, + ) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index 6e96eb5ed21..740e6aae9b2 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -105,7 +105,9 @@ async def async_validate_trigger_config( return config raise InvalidDeviceAutomationConfig( - f"Invalid ({CONF_TYPE},{CONF_SUBTYPE}): {trigger}" + translation_domain=DOMAIN, + translation_key="invalid_trigger", + translation_placeholders={"trigger": str(trigger)}, ) @@ -137,7 +139,11 @@ async def async_get_triggers( return triggers - raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") + raise InvalidDeviceAutomationConfig( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device": device_id}, + ) async def async_attach_trigger( diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 58ac34fc5ca..9ed3f47b41a 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_SLEEP_PERIOD, LOGGER +from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .utils import ( async_remove_shelly_entity, @@ -345,8 +345,12 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): except DeviceConnectionError as err: self.coordinator.last_update_success = False raise HomeAssistantError( - f"Setting state for entity {self.name} failed, state: {kwargs}, error:" - f" {err!r}" + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() @@ -406,13 +410,21 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): except DeviceConnectionError as err: self.coordinator.last_update_success = False raise HomeAssistantError( - f"Call RPC for {self.name} connection error, method: {method}, params:" - f" {params}, error: {err!r}" + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except RpcCallError as err: raise HomeAssistantError( - f"Call RPC for {self.name} request error, method: {method}, params:" - f" {params}, error: {err!r}" + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index bfd705f447a..ec5810581b1 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -34,7 +34,6 @@ from .utils import ( get_device_entry_gen, get_rpc_entity_name, get_rpc_key_instances, - get_rpc_script_event_types, is_block_momentary_input, is_rpc_momentary_input, ) @@ -109,18 +108,15 @@ async def async_setup_entry( script_instances = get_rpc_key_instances( coordinator.device.status, SCRIPT_EVENT.key ) + script_events = config_entry.runtime_data.rpc_script_events for script in script_instances: script_name = get_rpc_entity_name(coordinator.device, script) if script_name == BLE_SCRIPT_NAME: continue - event_types = await get_rpc_script_event_types( - coordinator.device, int(script.split(":")[-1]) - ) - if not event_types: - continue - - entities.append(ShellyRpcScriptEvent(coordinator, script, event_types)) + script_id = int(script.split(":")[-1]) + if script_events and (event_types := script_events[script_id]): + entities.append(ShellyRpcScriptEvent(coordinator, script, event_types)) # If a script is removed, from the device configuration, we need to remove orphaned entities async_remove_orphaned_entities( diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 59716f39c7f..c629eb4a57a 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, cast from aioshelly.block_device import Block -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_TIMEOUT, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.number import ( @@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceIn from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry -from .const import BLU_TRV_TIMEOUT, CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP +from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER, VIRTUAL_NUMBER_MODE_MAP from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -324,8 +324,12 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber): except DeviceConnectionError as err: self.coordinator.last_update_success = False raise HomeAssistantError( - f"Setting state for entity {self.name} failed, state: {params}, error:" - f" {err!r}" + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/quality_scale.yaml b/homeassistant/components/shelly/quality_scale.yaml new file mode 100644 index 00000000000..ac2a0756b5b --- /dev/null +++ b/homeassistant/components/shelly/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register services. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: + status: todo + comment: make sure flows end with created entry or abort + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not register services. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: todo + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration does not register services. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The integration connects to a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: todo + comment: BLU TRV needs to be removed when un-paired + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index ba9a8492194..3465891dc68 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -17,12 +17,20 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Username for the device's web panel.", + "password": "Password for the device's web panel." } }, "reauth_confirm": { "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::shelly::config::step::credentials::data_description::username%]", + "password": "[%key:component::shelly::config::step::credentials::data_description::password%]" } }, "confirm_discovery": { @@ -87,8 +95,15 @@ "description": "Bluetooth scanning can be active or passive. With active, the Shelly requests data from nearby devices; with passive, the Shelly receives unsolicited data from nearby devices.", "data": { "ble_scanner_mode": "Bluetooth scanner mode" + }, + "data_description": { + "ble_scanner_mode": "The scanner mode to use for Bluetooth scanning." } } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_scripts_support": "Device does not support scripts and cannot be used as a Bluetooth scanner." } }, "selector": { @@ -195,14 +210,55 @@ "state": { "checking": "Checking", "closed": "[%key:common::state::closed%]", - "closing": "Closing", + "closing": "[%key:common::state::closing%]", "failure": "Failure", "opened": "Opened", - "opening": "Opening" + "opening": "[%key:common::state::opening%]" } } } }, + "exceptions": { + "auth_error": { + "message": "Authentication failed for {device}, please update your credentials" + }, + "device_communication_error": { + "message": "Device communication error occurred for {device}" + }, + "device_communication_action_error": { + "message": "Device communication error occurred while calling action for {entity} of {device}" + }, + "device_not_found": { + "message": "{device} not found while configuring device automation triggers" + }, + "firmware_unsupported": { + "message": "{device} is running an unsupported firmware, please update the firmware" + }, + "invalid_trigger": { + "message": "Invalid device automation trigger (type, subtype): {trigger}" + }, + "ota_update_connection_error": { + "message": "Device communication error occurred while triggering OTA update for {device}" + }, + "ota_update_rpc_error": { + "message": "RPC call error occurred while triggering OTA update for {device}" + }, + "rpc_call_action_error": { + "message": "RPC call error occurred while calling action for {entity} of {device}" + }, + "update_error": { + "message": "An error occurred while retrieving data from {device}" + }, + "update_error_device_disconnected": { + "message": "An error occurred while retrieving data from {device} because it is disconnected" + }, + "update_error_reconnect_error": { + "message": "An error occurred while reconnecting to {device}" + }, + "update_error_sleeping_device": { + "message": "Sleeping device did not update within {period} seconds interval" + } + }, "issues": { "device_not_calibrated": { "title": "Shelly device {device_name} is not calibrated", diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index b1aa84b2640..12ce6dc70cd 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -25,7 +25,14 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import CONF_SLEEP_PERIOD, OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS +from .const import ( + CONF_SLEEP_PERIOD, + DOMAIN, + OTA_BEGIN, + OTA_ERROR, + OTA_PROGRESS, + OTA_SUCCESS, +) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( RestEntityDescription, @@ -198,7 +205,11 @@ class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity): try: result = await self.coordinator.device.trigger_ota_update(beta=beta) except DeviceConnectionError as err: - raise HomeAssistantError(f"Error starting OTA update: {err!r}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ota_update_connection_error", + translation_placeholders={"device": self.coordinator.name}, + ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() else: @@ -310,9 +321,20 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): try: await self.coordinator.device.trigger_ota_update(beta=beta) except DeviceConnectionError as err: - raise HomeAssistantError(f"OTA update connection error: {err!r}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ota_update_connection_error", + translation_placeholders={"device": self.coordinator.name}, + ) from err except RpcCallError as err: - raise HomeAssistantError(f"OTA update request error: {err!r}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ota_update_rpc_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() else: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 19897dbb185..474e2bb9410 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -664,3 +664,20 @@ def get_shelly_air_lamp_life(lamp_seconds: int) -> float: if lamp_hours >= SHAIR_MAX_WORK_HOURS: return 0.0 return 100 * (1 - lamp_hours / SHAIR_MAX_WORK_HOURS) + + +async def get_rpc_scripts_event_types( + device: RpcDevice, ignore_scripts: list[str] +) -> dict[int, list[str]]: + """Return a dict of all scripts and their event types.""" + script_instances = get_rpc_key_instances(device.status, "script") + script_events = {} + for script in script_instances: + script_name = get_rpc_entity_name(device, script) + if script_name in ignore_scripts: + continue + + script_id = int(script.split(":")[-1]) + script_events[script_id] = await get_rpc_script_event_types(device, script_id) + + return script_events diff --git a/homeassistant/components/siemens/__init__.py b/homeassistant/components/siemens/__init__.py new file mode 100644 index 00000000000..314b7c63da9 --- /dev/null +++ b/homeassistant/components/siemens/__init__.py @@ -0,0 +1 @@ +"""Siemens virtual integration.""" diff --git a/homeassistant/components/siemens/manifest.json b/homeassistant/components/siemens/manifest.json new file mode 100644 index 00000000000..e53aca0895f --- /dev/null +++ b/homeassistant/components/siemens/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "siemens", + "name": "Siemens", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/skybell/config_flow.py b/homeassistant/components/skybell/config_flow.py index a32441f4cf8..9893d0dd93a 100644 --- a/homeassistant/components/skybell/config_flow.py +++ b/homeassistant/components/skybell/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from aioskybell import Skybell, exceptions @@ -14,6 +15,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class SkybellFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Skybell.""" @@ -95,6 +98,7 @@ class SkybellFlowHandler(ConfigFlow, domain=DOMAIN): return None, "invalid_auth" except exceptions.SkybellException: return None, "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return None, "unknown" return skybell.user_id, None diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index bdafbfb6c77..634202d6da8 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -28,10 +28,10 @@ "select": { "foot_warmer_temp": { "state": { - "off": "Off", - "low": "Low", - "medium": "Medium", - "high": "High" + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } diff --git a/homeassistant/components/slide_local/strings.json b/homeassistant/components/slide_local/strings.json index 67514ff0d50..10efa4bc4f2 100644 --- a/homeassistant/components/slide_local/strings.json +++ b/homeassistant/components/slide_local/strings.json @@ -25,7 +25,7 @@ }, "zeroconf_confirm": { "title": "Confirm setup for Slide", - "description": "Do you want to setup {host}?" + "description": "Do you want to set up {host}?" } }, "abort": { diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json index 2966b5cd753..3037fbc98f6 100644 --- a/homeassistant/components/smappee/strings.json +++ b/homeassistant/components/smappee/strings.json @@ -15,7 +15,7 @@ } }, "zeroconf_confirm": { - "description": "Do you want to add the Smappee device with serialnumber `{serialnumber}` to Home Assistant?", + "description": "Do you want to add the Smappee device with serial number `{serialnumber}` to Home Assistant?", "title": "Discovered Smappee device" }, "pick_implementation": { diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 58afbb6cb41..c8ca1a819e0 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -3,25 +3,36 @@ from __future__ import annotations from collections.abc import Callable +import contextlib from dataclasses import dataclass +from http import HTTPStatus import logging -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast -from aiohttp import ClientError +from aiohttp import ClientResponseError from pysmartthings import ( Attribute, Capability, + ComponentStatus, Device, DeviceEvent, + Lifecycle, Scene, SmartThings, SmartThingsAuthenticationFailedError, + SmartThingsConnectionError, SmartThingsSinkError, Status, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_HW_VERSION, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, + ATTR_VIA_DEVICE, CONF_ACCESS_TOKEN, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, @@ -29,14 +40,16 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) +from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from .const import ( + BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, CONF_SUBSCRIPTION_ID, @@ -44,6 +57,7 @@ from .const import ( EVENT_BUTTON, MAIN, OLD_DATA, + SENSOR_ATTRIBUTES_TO_CAPABILITIES, ) _LOGGER = logging.getLogger(__name__) @@ -64,21 +78,27 @@ class FullDevice: """Define an object to hold device data.""" device: Device - status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]] + status: dict[str, ComponentStatus] type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, + Platform.EVENT, Platform.FAN, Platform.LIGHT, Platform.LOCK, + Platform.MEDIA_PLAYER, + Platform.NUMBER, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, Platform.VALVE, ] @@ -94,7 +114,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) try: await session.async_ensure_token_valid() - except ClientError as err: + except ClientResponseError as err: + if err.status == HTTPStatus.BAD_REQUEST: + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from err raise ConfigEntryNotReady from err client = SmartThings(session=async_get_clientsession(hass)) @@ -109,7 +131,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) client.refresh_token_function = _refresh_token def _handle_max_connections() -> None: - _LOGGER.debug("We hit the limit of max connections") + _LOGGER.debug( + "We hit the limit of max connections or we could not remove the old one, so retrying" + ) hass.config_entries.async_schedule_reload(entry.entry_id) client.max_connections_reached_callback = _handle_max_connections @@ -132,7 +156,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) if (old_identifier := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: _LOGGER.debug("Trying to delete old subscription %s", old_identifier) - await client.delete_subscription(old_identifier) + try: + await client.delete_subscription(old_identifier) + except SmartThingsConnectionError as err: + raise ConfigEntryNotReady("Could not delete old subscription") from err _LOGGER.debug("Trying to create a new subscription") try: @@ -170,30 +197,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) raise ConfigEntryAuthFailed from err device_registry = dr.async_get(hass) - for dev in device_status.values(): - for component in dev.device.components: - if component.id == MAIN and Capability.BRIDGE in component.capabilities: - assert dev.device.hub - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, dev.device.device_id)}, - connections=( - {(dr.CONNECTION_NETWORK_MAC, dev.device.hub.mac_address)} - if dev.device.hub.mac_address - else set() - ), - name=dev.device.label, - sw_version=dev.device.hub.firmware_version, - model=dev.device.hub.hardware_type, - suggested_area=( - rooms.get(dev.device.room_id) if dev.device.room_id else None - ), - ) + create_devices(device_registry, device_status, entry, rooms) + scenes = { scene.scene_id: scene for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) } + def handle_deleted_device(device_id: str) -> None: + """Handle a deleted device.""" + dev_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)}, + ) + if dev_entry is not None: + device_registry.async_update_device( + dev_entry.id, remove_config_entry_id=entry.entry_id + ) + + entry.async_on_unload( + client.add_device_lifecycle_event_listener( + Lifecycle.DELETE, handle_deleted_device + ) + ) + entry.runtime_data = SmartThingsData( devices={ device_id: device @@ -205,6 +231,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) rooms=rooms, ) + # Events are deprecated and will be removed in 2025.10 def handle_button_press(event: DeviceEvent) -> None: """Handle a button press.""" if ( @@ -259,7 +286,8 @@ async def async_unload_entry( """Unload a config entry.""" client = entry.runtime_data.client if (subscription_id := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: - await client.delete_subscription(subscription_id) + with contextlib.suppress(SmartThingsConnectionError): + await client.delete_subscription(subscription_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -272,9 +300,166 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, version=3, data={OLD_DATA: dict(entry.data)} ) + if entry.minor_version < 2: + + def migrate_entities(entity_entry: RegistryEntry) -> dict[str, Any] | None: + if entity_entry.domain == "binary_sensor": + device_id, attribute = entity_entry.unique_id.split(".") + if ( + capability := BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES.get( + attribute + ) + ) is None: + return None + new_unique_id = ( + f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}" + ) + return { + "new_unique_id": new_unique_id, + } + if entity_entry.domain in {"cover", "climate", "fan", "light", "lock"}: + return {"new_unique_id": f"{entity_entry.unique_id}_{MAIN}"} + if entity_entry.domain == "sensor": + delimiter = "." if " " not in entity_entry.unique_id else " " + if delimiter not in entity_entry.unique_id: + return None + device_id, attribute = entity_entry.unique_id.split( + delimiter, maxsplit=1 + ) + if ( + capability := SENSOR_ATTRIBUTES_TO_CAPABILITIES.get(attribute) + ) is None: + if attribute in { + "energy_meter", + "power_meter", + "deltaEnergy_meter", + "powerEnergy_meter", + "energySaved_meter", + }: + return { + "new_unique_id": f"{device_id}_{MAIN}_{Capability.POWER_CONSUMPTION_REPORT}_{Attribute.POWER_CONSUMPTION}_{attribute}", + } + if attribute in { + "X Coordinate", + "Y Coordinate", + "Z Coordinate", + }: + new_attribute = { + "X Coordinate": "x_coordinate", + "Y Coordinate": "y_coordinate", + "Z Coordinate": "z_coordinate", + }[attribute] + return { + "new_unique_id": f"{device_id}_{MAIN}_{Capability.THREE_AXIS}_{Attribute.THREE_AXIS}_{new_attribute}", + } + if attribute in { + Attribute.MACHINE_STATE, + Attribute.COMPLETION_TIME, + }: + capability = determine_machine_type( + hass, entry.entry_id, device_id + ) + if capability is None: + return None + return { + "new_unique_id": f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}", + } + return None + return { + "new_unique_id": f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}", + } + + if entity_entry.domain == "switch": + return { + "new_unique_id": f"{entity_entry.unique_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + } + + return None + + await async_migrate_entries(hass, entry.entry_id, migrate_entities) + hass.config_entries.async_update_entry( + entry, + minor_version=2, + ) + return True +def determine_machine_type( + hass: HomeAssistant, + entry_id: str, + device_id: str, +) -> Capability | None: + """Determine the machine type for a device.""" + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_registry, entry_id) + device_entries = [entry for entry in entries if device_id in entry.unique_id] + for entry in device_entries: + if Attribute.DISHWASHER_JOB_STATE in entry.unique_id: + return Capability.DISHWASHER_OPERATING_STATE + if Attribute.WASHER_JOB_STATE in entry.unique_id: + return Capability.WASHER_OPERATING_STATE + if Attribute.DRYER_JOB_STATE in entry.unique_id: + return Capability.DRYER_OPERATING_STATE + if Attribute.OVEN_JOB_STATE in entry.unique_id: + return Capability.OVEN_OPERATING_STATE + return None + + +def create_devices( + device_registry: dr.DeviceRegistry, + devices: dict[str, FullDevice], + entry: SmartThingsConfigEntry, + rooms: dict[str, str], +) -> None: + """Create devices in the device registry.""" + for device in sorted( + devices.values(), key=lambda d: d.device.parent_device_id or "" + ): + kwargs: dict[str, Any] = {} + if device.device.hub is not None: + kwargs = { + ATTR_SW_VERSION: device.device.hub.firmware_version, + ATTR_MODEL: device.device.hub.hardware_type, + } + if device.device.hub.mac_address: + kwargs[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, device.device.hub.mac_address) + } + if device.device.parent_device_id and device.device.parent_device_id in devices: + kwargs[ATTR_VIA_DEVICE] = (DOMAIN, device.device.parent_device_id) + if (ocf := device.device.ocf) is not None: + kwargs.update( + { + ATTR_MANUFACTURER: ocf.manufacturer_name, + ATTR_MODEL: ( + (ocf.model_number.split("|")[0]) if ocf.model_number else None + ), + ATTR_HW_VERSION: ocf.hardware_version, + ATTR_SW_VERSION: ocf.firmware_version, + } + ) + if (viper := device.device.viper) is not None: + kwargs.update( + { + ATTR_MANUFACTURER: viper.manufacturer_name, + ATTR_MODEL: viper.model_name, + ATTR_HW_VERSION: viper.hardware_version, + ATTR_SW_VERSION: viper.software_version, + } + ) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, device.device.device_id)}, + configuration_url="https://account.smartthings.com", + name=device.device.label, + suggested_area=( + rooms.get(device.device.room_id) if device.device.room_id else None + ), + **kwargs, + ) + + KEEP_CAPABILITY_QUIRK: dict[ Capability | str, Callable[[dict[Attribute | str, Status]], bool] ] = { @@ -288,14 +473,32 @@ KEEP_CAPABILITY_QUIRK: dict[ } -def process_status( - status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], -) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: +def process_status(status: dict[str, ComponentStatus]) -> dict[str, ComponentStatus]: """Remove disabled capabilities from status.""" if (main_component := status.get(MAIN)) is None: return status if ( - disabled_capabilities_capability := main_component.get( + disabled_components_capability := main_component.get( + Capability.CUSTOM_DISABLED_COMPONENTS + ) + ) is not None: + disabled_components = cast( + list[str], + disabled_components_capability[Attribute.DISABLED_COMPONENTS].value, + ) + if disabled_components is not None: + for component in disabled_components: + if component in status: + del status[component] + for component_status in status.values(): + process_component_status(component_status) + return status + + +def process_component_status(status: ComponentStatus) -> None: + """Remove disabled capabilities from component status.""" + if ( + disabled_capabilities_capability := status.get( Capability.CUSTOM_DISABLED_CAPABILITIES ) ) is not None: @@ -305,9 +508,8 @@ def process_status( ) if disabled_capabilities is not None: for capability in disabled_capabilities: - if capability in main_component and ( + if capability in status and ( capability not in KEEP_CAPABILITY_QUIRK - or not KEEP_CAPABILITY_QUIRK[capability](main_component[capability]) + or not KEEP_CAPABILITY_QUIRK[capability](status[capability]) ): - del main_component[capability] - return status + del status[capability] diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 0654846273e..0fe0e7fe919 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -2,30 +2,26 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import Attribute, Capability, Category, SmartThings, Status -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from . import FullDevice, SmartThingsConfigEntry -from .const import DOMAIN, MAIN +from .const import INVALID_SWITCH_CATEGORIES, MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity @dataclass(frozen=True, kw_only=True) @@ -33,6 +29,13 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): """Describe a SmartThings binary sensor entity.""" is_on_key: str + category_device_class: dict[Category | str, BinarySensorDeviceClass] | None = None + category: set[Category] | None = None + exists_fn: Callable[[str], bool] | None = None + component_translation_key: dict[str, str] | None = None + deprecated_fn: Callable[ + [dict[str, dict[Capability | str, dict[Attribute | str, Status]]]], str | None + ] = lambda _: None CAPABILITY_TO_SENSORS: dict[ @@ -51,6 +54,29 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.CONTACT, device_class=BinarySensorDeviceClass.DOOR, is_on_key="open", + category_device_class={ + Category.GARAGE_DOOR: BinarySensorDeviceClass.GARAGE_DOOR, + Category.DOOR: BinarySensorDeviceClass.DOOR, + Category.WINDOW: BinarySensorDeviceClass.WINDOW, + }, + exists_fn=lambda key: key in {"freezer", "cooler"}, + component_translation_key={ + "freezer": "freezer_door", + "cooler": "cooler_door", + }, + deprecated_fn=( + lambda status: "fridge_door" + if "freezer" in status and "cooler" in status + else None + ), + ) + }, + Capability.CUSTOM_DRYER_WRINKLE_PREVENT: { + Attribute.OPERATING_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.OPERATING_STATE, + translation_key="dryer_wrinkle_prevent_active", + is_on_key="running", + entity_category=EntityCategory.DIAGNOSTIC, ) }, Capability.FILTER_STATUS: { @@ -61,6 +87,13 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="replace", ) }, + Capability.SAMSUNG_CE_KIDS_LOCK: { + Attribute.LOCK_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.LOCK_STATE, + translation_key="child_lock", + is_on_key="locked", + ) + }, Capability.MOTION_SENSOR: { Attribute.MOTION: SmartThingsBinarySensorEntityDescription( key=Attribute.MOTION, @@ -89,6 +122,14 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="detected", ) }, + Capability.SWITCH: { + Attribute.SWITCH: SmartThingsBinarySensorEntityDescription( + key=Attribute.SWITCH, + device_class=BinarySensorDeviceClass.POWER, + is_on_key="on", + category=INVALID_SWITCH_CATEGORIES, + ) + }, Capability.TAMPER_ALERT: { Attribute.TAMPER: SmartThingsBinarySensorEntityDescription( key=Attribute.TAMPER, @@ -103,6 +144,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="valve", device_class=BinarySensorDeviceClass.OPENING, is_on_key="open", + deprecated_fn=lambda _: "valve", ) }, Capability.WATER_SENSOR: { @@ -112,9 +154,25 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="wet", ) }, + Capability.SAMSUNG_CE_DOOR_STATE: { + Attribute.DOOR_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.DOOR_STATE, + translation_key="door", + device_class=BinarySensorDeviceClass.OPENING, + is_on_key="open", + ) + }, } +def get_main_component_category( + device: FullDevice, +) -> Category | str: + """Get the main component of a device.""" + main = device.device.components[MAIN] + return main.user_category or main.manufacturer_category + + async def async_setup_entry( hass: HomeAssistant, entry: SmartThingsConfigEntry, @@ -122,20 +180,64 @@ async def async_setup_entry( ) -> None: """Add binary sensors for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsBinarySensor( - entry_data.client, - device, - description, - entry_data.rooms, - capability, - attribute, - ) - for device in entry_data.devices.values() - for capability, attribute_map in CAPABILITY_TO_SENSORS.items() - if capability in device.status[MAIN] - for attribute, description in attribute_map.items() - ) + entities = [] + + entity_registry = er.async_get(hass) + + for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks + for capability, attribute_map in CAPABILITY_TO_SENSORS.items(): + for attribute, description in attribute_map.items(): + for component in device.status: + if ( + capability in device.status[component] + and ( + component == MAIN + or ( + description.exists_fn is not None + and description.exists_fn(component) + ) + ) + and ( + not description.category + or get_main_component_category(device) + in description.category + ) + ): + if ( + component == MAIN + and (issue := description.deprecated_fn(device.status)) + is not None + ): + if deprecate_entity( + hass, + entity_registry, + BINARY_SENSOR_DOMAIN, + f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}", + f"deprecated_binary_{issue}", + ): + entities.append( + SmartThingsBinarySensor( + entry_data.client, + device, + description, + capability, + attribute, + component, + ) + ) + continue + entities.append( + SmartThingsBinarySensor( + entry_data.client, + device, + description, + capability, + attribute, + component, + ) + ) + + async_add_entities(entities) class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): @@ -148,16 +250,33 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsBinarySensorEntityDescription, - rooms: dict[str, str], capability: Capability, attribute: Attribute, + component: str, ) -> None: """Init the class.""" - super().__init__(client, device, rooms, {capability}) + super().__init__(client, device, {capability}, component=component) self._attribute = attribute self.capability = capability self.entity_description = entity_description - self._attr_unique_id = f"{device.device.device_id}.{attribute}" + self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}" + if ( + entity_description.category_device_class + and (category := get_main_component_category(device)) + in entity_description.category_device_class + ): + self._attr_device_class = entity_description.category_device_class[category] + self._attr_name = None + if ( + entity_description.component_translation_key is not None + and ( + translation_key := entity_description.component_translation_key.get( + component + ) + ) + is not None + ): + self._attr_translation_key = translation_key @property def is_on(self) -> bool: @@ -166,55 +285,3 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self.get_attribute_value(self.capability, self._attribute) == self.entity_description.is_on_key ) - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - if self.capability is not Capability.VALVE: - return - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - items = automations + scripts - if not items: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - entity_automations = [ - automation_entity - for automation_id in automations - if (automation_entity := entity_reg.async_get(automation_id)) - ] - entity_scripts = [ - script_entity - for script_id in scripts - if (script_entity := entity_reg.async_get(script_id)) - ] - - items_list = [ - f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" - for item in entity_automations - ] + [ - f"- [{item.original_name}](/config/script/edit/{item.unique_id})" - for item in entity_scripts - ] - - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_valve_{self.entity_id}", - breaks_in_ha_version="2025.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_binary_valve", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_valve_{self.entity_id}" - ) diff --git a/homeassistant/components/smartthings/button.py b/homeassistant/components/smartthings/button.py new file mode 100644 index 00000000000..00fbaa0e2c4 --- /dev/null +++ b/homeassistant/components/smartthings/button.py @@ -0,0 +1,78 @@ +"""Support for button entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pysmartthings import Capability, Command, SmartThings + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsButtonDescription(ButtonEntityDescription): + """Class describing SmartThings button entities.""" + + key: Capability + command: Command + + +CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] = { + Capability.OVEN_OPERATING_STATE: SmartThingsButtonDescription( + key=Capability.OVEN_OPERATING_STATE, + translation_key="stop", + command=Command.STOP, + ), + Capability.CUSTOM_WATER_FILTER: SmartThingsButtonDescription( + key=Capability.CUSTOM_WATER_FILTER, + translation_key="reset_water_filter", + command=Command.RESET_WATER_FILTER, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add button entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsButtonEntity( + entry_data.client, device, CAPABILITIES_TO_BUTTONS[capability] + ) + for device in entry_data.devices.values() + for capability in device.status[MAIN] + if capability in CAPABILITIES_TO_BUTTONS + ) + + +class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity): + """Define a SmartThings button.""" + + entity_description: SmartThingsButtonDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsButtonDescription, + ) -> None: + """Initialize the instance.""" + super().__init__(client, device, set()) + self.entity_description = entity_description + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{entity_description.key}_{entity_description.command}" + + async def async_press(self) -> None: + """Press the button.""" + await self.execute_device_command( + self.entity_description.key, + self.entity_description.command, + ) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index c6dee3e2be4..f2f9479584c 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -118,12 +118,12 @@ async def async_setup_entry( """Add climate entities for a config entry.""" entry_data = entry.runtime_data entities: list[ClimateEntity] = [ - SmartThingsAirConditioner(entry_data.client, entry_data.rooms, device) + SmartThingsAirConditioner(entry_data.client, device) for device in entry_data.devices.values() if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ] entities.extend( - SmartThingsThermostat(entry_data.client, entry_data.rooms, device) + SmartThingsThermostat(entry_data.client, device) for device in entry_data.devices.values() if all( capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES @@ -137,14 +137,11 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): _attr_name = None - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( client, device, - rooms, { Capability.THERMOSTAT_FAN_MODE, Capability.THERMOSTAT_MODE, @@ -284,7 +281,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): return [ state for mode in supported_thermostat_modes - if (state := AC_MODE_TO_STATE.get(mode)) is not None + if (state := MODE_TO_STATE.get(mode)) is not None ] @property @@ -336,16 +333,12 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" _attr_name = None - _attr_preset_mode = None - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( client, device, - rooms, { Capability.AIR_CONDITIONER_MODE, Capability.SWITCH, @@ -472,12 +465,14 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Capability.DEMAND_RESPONSE_LOAD_CONTROL, Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS, ) - return { - "drlc_status_duration": drlc_status["duration"], - "drlc_status_level": drlc_status["drlcLevel"], - "drlc_status_start": drlc_status["start"], - "drlc_status_override": drlc_status["override"], - } + res = {} + for key in ("duration", "start", "override", "drlcLevel"): + if key in drlc_status: + dict_key = {"drlcLevel": "drlc_status_level"}.get( + key, f"drlc_status_{key}" + ) + res[dict_key] = drlc_status[key] + return res @property def fan_mode(self) -> str: @@ -549,6 +544,18 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): SWING_OFF, ) + @property + def preset_mode(self) -> str | None: + """Return the preset mode.""" + if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): + mode = self.get_attribute_value( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.AC_OPTIONAL_MODE, + ) + if mode == WINDFREE: + return WINDFREE + return None + def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index d2654348527..03c8e4bfa66 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -20,6 +20,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle configuration of SmartThings integrations.""" VERSION = 3 + MINOR_VERSION = 2 DOMAIN = DOMAIN @property diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 2ba59ade4e8..8f27b785688 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -1,5 +1,7 @@ """Constants used by the SmartThings component and platforms.""" +from pysmartthings import Attribute, Capability, Category + DOMAIN = "smartthings" SCOPES = [ @@ -35,3 +37,84 @@ OLD_DATA = "old_data" CONF_SUBSCRIPTION_ID = "subscription_id" EVENT_BUTTON = "smartthings.button" + +BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = { + Attribute.ACCELERATION: Capability.ACCELERATION_SENSOR, + Attribute.CONTACT: Capability.CONTACT_SENSOR, + Attribute.FILTER_STATUS: Capability.FILTER_STATUS, + Attribute.MOTION: Capability.MOTION_SENSOR, + Attribute.PRESENCE: Capability.PRESENCE_SENSOR, + Attribute.SOUND: Capability.SOUND_SENSOR, + Attribute.TAMPER: Capability.TAMPER_ALERT, + Attribute.VALVE: Capability.VALVE, + Attribute.WATER: Capability.WATER_SENSOR, +} + +SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = { + Attribute.LIGHTING_MODE: Capability.ACTIVITY_LIGHTING_MODE, + Attribute.AIR_CONDITIONER_MODE: Capability.AIR_CONDITIONER_MODE, + Attribute.AIR_QUALITY: Capability.AIR_QUALITY_SENSOR, + Attribute.ALARM: Capability.ALARM, + Attribute.BATTERY: Capability.BATTERY, + Attribute.BMI_MEASUREMENT: Capability.BODY_MASS_INDEX_MEASUREMENT, + Attribute.BODY_WEIGHT_MEASUREMENT: Capability.BODY_WEIGHT_MEASUREMENT, + Attribute.CARBON_DIOXIDE: Capability.CARBON_DIOXIDE_MEASUREMENT, + Attribute.CARBON_MONOXIDE: Capability.CARBON_MONOXIDE_MEASUREMENT, + Attribute.CARBON_MONOXIDE_LEVEL: Capability.CARBON_MONOXIDE_MEASUREMENT, + Attribute.DISHWASHER_JOB_STATE: Capability.DISHWASHER_OPERATING_STATE, + Attribute.DRYER_MODE: Capability.DRYER_MODE, + Attribute.DRYER_JOB_STATE: Capability.DRYER_OPERATING_STATE, + Attribute.DUST_LEVEL: Capability.DUST_SENSOR, + Attribute.FINE_DUST_LEVEL: Capability.DUST_SENSOR, + Attribute.ENERGY: Capability.ENERGY_METER, + Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT, + Attribute.FORMALDEHYDE_LEVEL: Capability.FORMALDEHYDE_MEASUREMENT, + Attribute.GAS_METER: Capability.GAS_METER, + Attribute.GAS_METER_CALORIFIC: Capability.GAS_METER, + Attribute.GAS_METER_TIME: Capability.GAS_METER, + Attribute.GAS_METER_VOLUME: Capability.GAS_METER, + Attribute.ILLUMINANCE: Capability.ILLUMINANCE_MEASUREMENT, + Attribute.INFRARED_LEVEL: Capability.INFRARED_LEVEL, + Attribute.INPUT_SOURCE: Capability.MEDIA_INPUT_SOURCE, + Attribute.PLAYBACK_REPEAT_MODE: Capability.MEDIA_PLAYBACK_REPEAT, + Attribute.PLAYBACK_SHUFFLE: Capability.MEDIA_PLAYBACK_SHUFFLE, + Attribute.PLAYBACK_STATUS: Capability.MEDIA_PLAYBACK, + Attribute.ODOR_LEVEL: Capability.ODOR_SENSOR, + Attribute.OVEN_MODE: Capability.OVEN_MODE, + Attribute.OVEN_JOB_STATE: Capability.OVEN_OPERATING_STATE, + Attribute.OVEN_SETPOINT: Capability.OVEN_SETPOINT, + Attribute.POWER: Capability.POWER_METER, + Attribute.POWER_SOURCE: Capability.POWER_SOURCE, + Attribute.REFRIGERATION_SETPOINT: Capability.REFRIGERATION_SETPOINT, + Attribute.HUMIDITY: Capability.RELATIVE_HUMIDITY_MEASUREMENT, + Attribute.ROBOT_CLEANER_CLEANING_MODE: Capability.ROBOT_CLEANER_CLEANING_MODE, + Attribute.ROBOT_CLEANER_MOVEMENT: Capability.ROBOT_CLEANER_MOVEMENT, + Attribute.ROBOT_CLEANER_TURBO_MODE: Capability.ROBOT_CLEANER_TURBO_MODE, + Attribute.LQI: Capability.SIGNAL_STRENGTH, + Attribute.RSSI: Capability.SIGNAL_STRENGTH, + Attribute.SMOKE: Capability.SMOKE_DETECTOR, + Attribute.TEMPERATURE: Capability.TEMPERATURE_MEASUREMENT, + Attribute.COOLING_SETPOINT: Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.THERMOSTAT_FAN_MODE: Capability.THERMOSTAT_FAN_MODE, + Attribute.HEATING_SETPOINT: Capability.THERMOSTAT_HEATING_SETPOINT, + Attribute.THERMOSTAT_MODE: Capability.THERMOSTAT_MODE, + Attribute.THERMOSTAT_OPERATING_STATE: Capability.THERMOSTAT_OPERATING_STATE, + Attribute.THERMOSTAT_SETPOINT: Capability.THERMOSTAT_SETPOINT, + Attribute.TV_CHANNEL: Capability.TV_CHANNEL, + Attribute.TV_CHANNEL_NAME: Capability.TV_CHANNEL, + Attribute.TVOC_LEVEL: Capability.TVOC_MEASUREMENT, + Attribute.ULTRAVIOLET_INDEX: Capability.ULTRAVIOLET_INDEX, + Attribute.VERY_FINE_DUST_LEVEL: Capability.VERY_FINE_DUST_SENSOR, + Attribute.VOLTAGE: Capability.VOLTAGE_MEASUREMENT, + Attribute.WASHER_MODE: Capability.WASHER_MODE, + Attribute.WASHER_JOB_STATE: Capability.WASHER_OPERATING_STATE, +} + +INVALID_SWITCH_CATEGORIES = { + Category.CLOTHING_CARE_MACHINE, + Category.COOKTOP, + Category.DRYER, + Category.WASHER, + Category.MICROWAVE, + Category.DISHWASHER, +} diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 84bf0412ab4..0b68409443d 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -41,9 +41,7 @@ async def async_setup_entry( """Add covers for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsCover( - entry_data.client, device, entry_data.rooms, Capability(capability) - ) + SmartThingsCover(entry_data.client, device, Capability(capability)) for device in entry_data.devices.values() for capability in device.status[MAIN] if capability in CAPABILITIES @@ -60,14 +58,12 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): self, client: SmartThings, device: FullDevice, - rooms: dict[str, str], capability: Capability, ) -> None: """Initialize the cover class.""" super().__init__( client, device, - rooms, { capability, Capability.BATTERY, diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index c2637174a5c..5544297a4c6 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -8,9 +8,9 @@ from pysmartthings import ( Attribute, Capability, Command, + ComponentStatus, DeviceEvent, SmartThings, - Status, ) from homeassistant.helpers.device_registry import DeviceInfo @@ -30,52 +30,24 @@ class SmartThingsEntity(Entity): self, client: SmartThings, device: FullDevice, - rooms: dict[str, str], capabilities: set[Capability], + *, + component: str = MAIN, ) -> None: """Initialize the instance.""" self.client = client self.capabilities = capabilities - self._internal_state: dict[Capability | str, dict[Attribute | str, Status]] = { - capability: device.status[MAIN][capability] + self.component = component + self._internal_state: ComponentStatus = { + capability: device.status[component][capability] for capability in capabilities - if capability in device.status[MAIN] + if capability in device.status[component] } self.device = device - self._attr_unique_id = device.device.device_id + self._attr_unique_id = f"{device.device.device_id}_{component}" self._attr_device_info = DeviceInfo( - configuration_url="https://account.smartthings.com", identifiers={(DOMAIN, device.device.device_id)}, - name=device.device.label, - suggested_area=( - rooms.get(device.device.room_id) if device.device.room_id else None - ), ) - if device.device.parent_device_id: - self._attr_device_info["via_device"] = ( - DOMAIN, - device.device.parent_device_id, - ) - if (ocf := device.device.ocf) is not None: - self._attr_device_info.update( - { - "manufacturer": ocf.manufacturer_name, - "model": ( - (ocf.model_number.split("|")[0]) if ocf.model_number else None - ), - "hw_version": ocf.hardware_version, - "sw_version": ocf.firmware_version, - } - ) - if (viper := device.device.viper) is not None: - self._attr_device_info.update( - { - "manufacturer": viper.manufacturer_name, - "model": viper.model_name, - "hw_version": viper.hardware_version, - "sw_version": viper.software_version, - } - ) async def async_added_to_hass(self) -> None: """Subscribe to updates.""" @@ -84,7 +56,7 @@ class SmartThingsEntity(Entity): self.async_on_remove( self.client.add_device_capability_event_listener( self.device.device.device_id, - MAIN, + self.component, capability, self._update_handler, ) @@ -98,7 +70,7 @@ class SmartThingsEntity(Entity): def supports_capability(self, capability: Capability) -> bool: """Test if device supports a capability.""" - return capability in self.device.status[MAIN] + return capability in self.device.status[self.component] def get_attribute_value(self, capability: Capability, attribute: Attribute) -> Any: """Get the value of a device attribute.""" @@ -123,5 +95,5 @@ class SmartThingsEntity(Entity): if argument is not None: kwargs["argument"] = argument await self.client.execute_device_command( - self.device.device.device_id, capability, command, MAIN, **kwargs + self.device.device.device_id, capability, command, self.component, **kwargs ) diff --git a/homeassistant/components/smartthings/event.py b/homeassistant/components/smartthings/event.py new file mode 100644 index 00000000000..0439e6391f4 --- /dev/null +++ b/homeassistant/components/smartthings/event.py @@ -0,0 +1,63 @@ +"""Support for events through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import cast + +from pysmartthings import Attribute, Capability, Component, DeviceEvent, SmartThings + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add events for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsButtonEvent( + entry_data.client, device, device.device.components[component] + ) + for device in entry_data.devices.values() + for component, capabilities in device.status.items() + if Capability.BUTTON in capabilities + ) + + +class SmartThingsButtonEvent(SmartThingsEntity, EventEntity): + """Define a SmartThings event.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_translation_key = "button" + + def __init__( + self, + client: SmartThings, + device: FullDevice, + component: Component, + ) -> None: + """Init the class.""" + super().__init__(client, device, {Capability.BUTTON}, component=component.id) + self._attr_name = component.label + self._attr_unique_id = ( + f"{device.device.device_id}_{component.id}_{Capability.BUTTON}" + ) + + @property + def event_types(self) -> list[str]: + """Return the event types.""" + return self.get_attribute_value( + Capability.BUTTON, Attribute.SUPPORTED_BUTTON_VALUES + ) + + def _update_handler(self, event: DeviceEvent) -> None: + if event.attribute is Attribute.BUTTON: + self._trigger_event(cast(str, event.value)) + super()._update_handler(event) diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index ef3d9702ce2..1c4cb4edc4a 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -31,7 +31,7 @@ async def async_setup_entry( """Add fans for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsFan(entry_data.client, entry_data.rooms, device) + SmartThingsFan(entry_data.client, device) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and any( @@ -51,14 +51,11 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): _attr_name = None _attr_speed_count = int_states_in_range(SPEED_RANGE) - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( client, device, - rooms, { Capability.SWITCH, Capability.FAN_SPEED, diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index cbc4b6b80ce..214a9953a5a 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -1,11 +1,62 @@ { "entity": { "binary_sensor": { + "dryer_wrinkle_prevent_active": { + "default": "mdi:tumble-dryer", + "state": { + "on": "mdi:tumble-dryer-alert" + } + }, "remote_control": { "default": "mdi:remote-off", "state": { "on": "mdi:remote" } + }, + "child_lock": { + "default": "mdi:lock-open", + "state": { + "on": "mdi:lock" + } + } + }, + "button": { + "reset_water_filter": { + "default": "mdi:reload" + }, + "stop": { + "default": "mdi:stop" + } + }, + "number": { + "washer_rinse_cycles": { + "default": "mdi:waves-arrow-up" + } + }, + "select": { + "operating_state": { + "state": { + "run": "mdi:play", + "pause": "mdi:pause", + "stop": "mdi:stop" + } + } + }, + "switch": { + "bubble_soak": { + "default": "mdi:water-off", + "state": { + "on": "mdi:water" + } + }, + "wrinkle_prevent": { + "default": "mdi:tumble-dryer", + "state": { + "off": "mdi:tumble-dryer-off" + } + }, + "ice_maker": { + "default": "mdi:delete-variant" } } } diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 12c7f7ebbcb..1ad315bcd97 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -41,7 +41,7 @@ async def async_setup_entry( """Add lights for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLight(entry_data.client, entry_data.rooms, device) + SmartThingsLight(entry_data.client, device) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and any(capability in device.status[MAIN] for capability in CAPABILITIES) @@ -71,14 +71,11 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): # highest kelvin found supported across 20+ handlers. _attr_max_color_temp_kelvin = 9000 # 111 mireds - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize a SmartThingsLight.""" super().__init__( client, device, - rooms, { Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE, diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 76a643e417e..f56ecd5d565 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -33,7 +33,7 @@ async def async_setup_entry( """Add locks for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLock(entry_data.client, device, entry_data.rooms, {Capability.LOCK}) + SmartThingsLock(entry_data.client, device, {Capability.LOCK}) for device in entry_data.devices.values() if Capability.LOCK in device.status[MAIN] ) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index a456a6bef2f..dda7ef53cf5 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,6 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.7.4"] + "quality_scale": "bronze", + "requirements": ["pysmartthings==3.0.2"] } diff --git a/homeassistant/components/smartthings/media_player.py b/homeassistant/components/smartthings/media_player.py new file mode 100644 index 00000000000..9a676d2efb6 --- /dev/null +++ b/homeassistant/components/smartthings/media_player.py @@ -0,0 +1,355 @@ +"""Support for media players through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import Any + +from pysmartthings import Attribute, Capability, Category, Command, SmartThings + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + RepeatMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + +MEDIA_PLAYER_CAPABILITIES = ( + Capability.AUDIO_MUTE, + Capability.AUDIO_VOLUME, + Capability.MEDIA_PLAYBACK, +) + +CONTROLLABLE_SOURCES = ["bluetooth", "wifi"] + +DEVICE_CLASS_MAP: dict[Category | str, MediaPlayerDeviceClass] = { + Category.NETWORK_AUDIO: MediaPlayerDeviceClass.SPEAKER, + Category.SPEAKER: MediaPlayerDeviceClass.SPEAKER, + Category.TELEVISION: MediaPlayerDeviceClass.TV, + Category.RECEIVER: MediaPlayerDeviceClass.RECEIVER, +} + +VALUE_TO_STATE = { + "buffering": MediaPlayerState.BUFFERING, + "paused": MediaPlayerState.PAUSED, + "playing": MediaPlayerState.PLAYING, + "stopped": MediaPlayerState.IDLE, + "fast forwarding": MediaPlayerState.BUFFERING, + "rewinding": MediaPlayerState.BUFFERING, +} + +REPEAT_MODE_TO_HA = { + "all": RepeatMode.ALL, + "one": RepeatMode.ONE, + "off": RepeatMode.OFF, +} + +HA_REPEAT_MODE_TO_SMARTTHINGS = {v: k for k, v in REPEAT_MODE_TO_HA.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add media players for a config entry.""" + entry_data = entry.runtime_data + + async_add_entities( + SmartThingsMediaPlayer(entry_data.client, device) + for device in entry_data.devices.values() + if all( + capability in device.status[MAIN] + for capability in MEDIA_PLAYER_CAPABILITIES + ) + ) + + +class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): + """Define a SmartThings media player.""" + + _attr_name = None + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the media_player class.""" + super().__init__( + client, + device, + { + Capability.AUDIO_MUTE, + Capability.AUDIO_TRACK_DATA, + Capability.AUDIO_VOLUME, + Capability.MEDIA_INPUT_SOURCE, + Capability.MEDIA_PLAYBACK, + Capability.MEDIA_PLAYBACK_REPEAT, + Capability.MEDIA_PLAYBACK_SHUFFLE, + Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, + Capability.SWITCH, + }, + ) + self._attr_supported_features = self._determine_features() + self._attr_device_class = DEVICE_CLASS_MAP.get( + device.device.components[MAIN].user_category + or device.device.components[MAIN].manufacturer_category, + ) + + def _determine_features(self) -> MediaPlayerEntityFeature: + flags = MediaPlayerEntityFeature(0) + playback_commands = self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.SUPPORTED_PLAYBACK_COMMANDS + ) + if "play" in playback_commands: + flags |= MediaPlayerEntityFeature.PLAY + if "pause" in playback_commands: + flags |= MediaPlayerEntityFeature.PAUSE + if "stop" in playback_commands: + flags |= MediaPlayerEntityFeature.STOP + if "rewind" in playback_commands: + flags |= MediaPlayerEntityFeature.PREVIOUS_TRACK + if "fastForward" in playback_commands: + flags |= MediaPlayerEntityFeature.NEXT_TRACK + if self.supports_capability(Capability.AUDIO_VOLUME): + flags |= ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + ) + if self.supports_capability(Capability.AUDIO_MUTE): + flags |= MediaPlayerEntityFeature.VOLUME_MUTE + if self.supports_capability(Capability.SWITCH): + flags |= ( + MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF + ) + if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + flags |= MediaPlayerEntityFeature.SELECT_SOURCE + if self.supports_capability(Capability.MEDIA_PLAYBACK_SHUFFLE): + flags |= MediaPlayerEntityFeature.SHUFFLE_SET + if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT): + flags |= MediaPlayerEntityFeature.REPEAT_SET + return flags + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the media player off.""" + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the media player on.""" + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) + + async def async_mute_volume(self, mute: bool) -> None: + """Mute volume.""" + await self.execute_device_command( + Capability.AUDIO_MUTE, + Command.SET_MUTE, + argument="muted" if mute else "unmuted", + ) + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level.""" + await self.execute_device_command( + Capability.AUDIO_VOLUME, + Command.SET_VOLUME, + argument=int(volume * 100), + ) + + async def async_volume_up(self) -> None: + """Increase volume.""" + await self.execute_device_command( + Capability.AUDIO_VOLUME, + Command.VOLUME_UP, + ) + + async def async_volume_down(self) -> None: + """Decrease volume.""" + await self.execute_device_command( + Capability.AUDIO_VOLUME, + Command.VOLUME_DOWN, + ) + + async def async_media_play(self) -> None: + """Play media.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.PLAY, + ) + + async def async_media_pause(self) -> None: + """Pause media.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.PAUSE, + ) + + async def async_media_stop(self) -> None: + """Stop media.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.STOP, + ) + + async def async_media_previous_track(self) -> None: + """Previous track.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.REWIND, + ) + + async def async_media_next_track(self) -> None: + """Next track.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.FAST_FORWARD, + ) + + async def async_select_source(self, source: str) -> None: + """Select source.""" + await self.execute_device_command( + Capability.MEDIA_INPUT_SOURCE, + Command.SET_INPUT_SOURCE, + argument=source, + ) + + async def async_set_shuffle(self, shuffle: bool) -> None: + """Set shuffle mode.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK_SHUFFLE, + Command.SET_PLAYBACK_SHUFFLE, + argument="enabled" if shuffle else "disabled", + ) + + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK_REPEAT, + Command.SET_PLAYBACK_REPEAT_MODE, + argument=HA_REPEAT_MODE_TO_SMARTTHINGS[repeat], + ) + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + if ( + not self.supports_capability(Capability.AUDIO_TRACK_DATA) + or ( + track_data := self.get_attribute_value( + Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA + ) + ) + is None + ): + return None + return track_data.get("title", None) + + @property + def media_artist(self) -> str | None: + """Artist of current playing media.""" + if ( + not self.supports_capability(Capability.AUDIO_TRACK_DATA) + or ( + track_data := self.get_attribute_value( + Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA + ) + ) + is None + ): + return None + return track_data.get("artist") + + @property + def state(self) -> MediaPlayerState | None: + """State of the media player.""" + if self.supports_capability(Capability.SWITCH): + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on": + if ( + self.source is not None + and self.source in CONTROLLABLE_SOURCES + and self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS + ) + in VALUE_TO_STATE + ): + return VALUE_TO_STATE[ + self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS + ) + ] + return MediaPlayerState.ON + return MediaPlayerState.OFF + return VALUE_TO_STATE[ + self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS + ) + ] + + @property + def is_volume_muted(self) -> bool: + """Returns if the volume is muted.""" + return ( + self.get_attribute_value(Capability.AUDIO_MUTE, Attribute.MUTE) == "muted" + ) + + @property + def volume_level(self) -> float: + """Volume level.""" + return self.get_attribute_value(Capability.AUDIO_VOLUME, Attribute.VOLUME) / 100 + + @property + def source(self) -> str | None: + """Input source.""" + if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + return self.get_attribute_value( + Capability.MEDIA_INPUT_SOURCE, Attribute.INPUT_SOURCE + ) + if self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE): + return self.get_attribute_value( + Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, Attribute.INPUT_SOURCE + ) + return None + + @property + def source_list(self) -> list[str] | None: + """List of input sources.""" + if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + return self.get_attribute_value( + Capability.MEDIA_INPUT_SOURCE, Attribute.SUPPORTED_INPUT_SOURCES + ) + if self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE): + return self.get_attribute_value( + Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, + Attribute.SUPPORTED_INPUT_SOURCES, + ) + return None + + @property + def shuffle(self) -> bool | None: + """Returns if shuffle mode is set.""" + if self.supports_capability(Capability.MEDIA_PLAYBACK_SHUFFLE): + return ( + self.get_attribute_value( + Capability.MEDIA_PLAYBACK_SHUFFLE, Attribute.PLAYBACK_SHUFFLE + ) + == "enabled" + ) + return None + + @property + def repeat(self) -> RepeatMode | None: + """Returns if repeat mode is set.""" + if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT): + return REPEAT_MODE_TO_HA[ + self.get_attribute_value( + Capability.MEDIA_PLAYBACK_REPEAT, Attribute.PLAYBACK_REPEAT_MODE + ) + ] + return None diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py new file mode 100644 index 00000000000..2f2ac7903f2 --- /dev/null +++ b/homeassistant/components/smartthings/number.py @@ -0,0 +1,76 @@ +"""Support for number entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add number entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsWasherRinseCyclesNumberEntity(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.CUSTOM_WASHER_RINSE_CYCLES in device.status[MAIN] + ) + + +class SmartThingsWasherRinseCyclesNumberEntity(SmartThingsEntity, NumberEntity): + """Define a SmartThings number.""" + + _attr_translation_key = "washer_rinse_cycles" + _attr_native_step = 1.0 + _attr_mode = NumberMode.BOX + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the instance.""" + super().__init__(client, device, {Capability.CUSTOM_WASHER_RINSE_CYCLES}) + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_WASHER_RINSE_CYCLES}_{Attribute.WASHER_RINSE_CYCLES}_{Attribute.WASHER_RINSE_CYCLES}" + + @property + def options(self) -> list[int]: + """Return the list of options.""" + values = self.get_attribute_value( + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Attribute.SUPPORTED_WASHER_RINSE_CYCLES, + ) + return [int(value) for value in values] if values else [] + + @property + def native_value(self) -> float | None: + """Return the current value.""" + return int( + self.get_attribute_value( + Capability.CUSTOM_WASHER_RINSE_CYCLES, Attribute.WASHER_RINSE_CYCLES + ) + ) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + return min(self.options) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return max(self.options) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.execute_device_command( + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Command.SET_WASHER_RINSE_CYCLES, + str(int(value)), + ) diff --git a/homeassistant/components/smartthings/quality_scale.yaml b/homeassistant/components/smartthings/quality_scale.yaml new file mode 100644 index 00000000000..be8a9039617 --- /dev/null +++ b/homeassistant/components/smartthings/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration works via push. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: + status: exempt + comment: No parameters needed during installation + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration connects via the cloud. + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have any entities that are disabled by default. + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: done + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py new file mode 100644 index 00000000000..f0a483b1329 --- /dev/null +++ b/homeassistant/components/smartthings/select.py @@ -0,0 +1,127 @@ +"""Support for select entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsSelectDescription(SelectEntityDescription): + """Class describing SmartThings select entities.""" + + key: Capability + requires_remote_control_status: bool + options_attribute: Attribute + status_attribute: Attribute + command: Command + + +CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = { + Capability.DISHWASHER_OPERATING_STATE: SmartThingsSelectDescription( + key=Capability.DISHWASHER_OPERATING_STATE, + name=None, + translation_key="operating_state", + requires_remote_control_status=True, + options_attribute=Attribute.SUPPORTED_MACHINE_STATES, + status_attribute=Attribute.MACHINE_STATE, + command=Command.SET_MACHINE_STATE, + ), + Capability.DRYER_OPERATING_STATE: SmartThingsSelectDescription( + key=Capability.DRYER_OPERATING_STATE, + name=None, + translation_key="operating_state", + requires_remote_control_status=True, + options_attribute=Attribute.SUPPORTED_MACHINE_STATES, + status_attribute=Attribute.MACHINE_STATE, + command=Command.SET_MACHINE_STATE, + ), + Capability.WASHER_OPERATING_STATE: SmartThingsSelectDescription( + key=Capability.WASHER_OPERATING_STATE, + name=None, + translation_key="operating_state", + requires_remote_control_status=True, + options_attribute=Attribute.SUPPORTED_MACHINE_STATES, + status_attribute=Attribute.MACHINE_STATE, + command=Command.SET_MACHINE_STATE, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add select entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsSelectEntity( + entry_data.client, device, CAPABILITIES_TO_SELECT[capability] + ) + for device in entry_data.devices.values() + for capability in device.status[MAIN] + if capability in CAPABILITIES_TO_SELECT + ) + + +class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity): + """Define a SmartThings select.""" + + entity_description: SmartThingsSelectDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsSelectDescription, + ) -> None: + """Initialize the instance.""" + capabilities = {entity_description.key} + if entity_description.requires_remote_control_status: + capabilities.add(Capability.REMOTE_CONTROL_STATUS) + super().__init__(client, device, capabilities) + self.entity_description = entity_description + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{entity_description.key}_{entity_description.status_attribute}_{entity_description.status_attribute}" + + @property + def options(self) -> list[str]: + """Return the list of options.""" + return self.get_attribute_value( + self.entity_description.key, self.entity_description.options_attribute + ) + + @property + def current_option(self) -> str | None: + """Return the current option.""" + return self.get_attribute_value( + self.entity_description.key, self.entity_description.status_attribute + ) + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + if ( + self.entity_description.requires_remote_control_status + and self.get_attribute_value( + Capability.REMOTE_CONTROL_STATUS, Attribute.REMOTE_CONTROL_ENABLED + ) + == "false" + ): + raise ServiceValidationError( + "Can only be updated when remote control is enabled" + ) + await self.execute_device_command( + self.entity_description.key, + self.entity_description.command, + option, + ) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 1437cbe6000..346516be480 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -7,9 +7,10 @@ from dataclasses import dataclass from datetime import datetime from typing import Any, cast -from pysmartthings import Attribute, Capability, SmartThings, Status +from pysmartthings import Attribute, Capability, ComponentStatus, SmartThings, Status from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -29,12 +30,14 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import FullDevice, SmartThingsConfigEntry from .const import MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity THERMOSTAT_CAPABILITIES = { Capability.TEMPERATURE_MEASUREMENT, @@ -128,11 +131,11 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Any], str | float | int | datetime | None] = lambda value: value extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None - unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None exists_fn: Callable[[Status], bool] | None = None use_temperature_unit: bool = False + deprecated: Callable[[ComponentStatus], str | None] | None = None CAPABILITY_TO_SENSORS: dict[ @@ -189,6 +192,17 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.VOLUME, translation_key="audio_volume", native_unit_of_measurement=PERCENTAGE, + deprecated=( + lambda status: "media_player" + if all( + capability in status + for capability in ( + Capability.AUDIO_MUTE, + Capability.MEDIA_PLAYBACK, + ) + ) + else None + ), ) ] }, @@ -225,7 +239,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # Haven't seen at devices yet Capability.CARBON_DIOXIDE_MEASUREMENT: { Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( @@ -464,24 +477,25 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENUM, options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, value_fn=lambda value: value.lower() if value else None, + deprecated=lambda _: "media_player", ) ] }, - # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_REPEAT: { Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_REPEAT_MODE, translation_key="media_playback_repeat", + deprecated=lambda _: "media_player", ) ] }, - # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_SHUFFLE: { Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_SHUFFLE, translation_key="media_playback_shuffle", + deprecated=lambda _: "media_player", ) ] }, @@ -500,6 +514,7 @@ CAPABILITY_TO_SENSORS: dict[ ], device_class=SensorDeviceClass.ENUM, value_fn=lambda value: MEDIA_PLAYBACK_STATE_MAP.get(value, value), + deprecated=lambda _: "media_player", ) ] }, @@ -678,6 +693,15 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.RELATIVE_BRIGHTNESS: { + Attribute.BRIGHTNESS_INTENSITY: [ + SmartThingsSensorEntityDescription( + key=Attribute.BRIGHTNESS_INTENSITY, + translation_key="brightness_intensity", + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, Capability.RELATIVE_HUMIDITY_MEASUREMENT: { Attribute.HUMIDITY: [ SmartThingsSensorEntityDescription( @@ -849,21 +873,18 @@ CAPABILITY_TO_SENSORS: dict[ Capability.THREE_AXIS: { Attribute.THREE_AXIS: [ SmartThingsSensorEntityDescription( - key="X Coordinate", + key="x_coordinate", translation_key="x_coordinate", - unique_id_separator=" ", value_fn=lambda value: value[0], ), SmartThingsSensorEntityDescription( - key="Y Coordinate", + key="y_coordinate", translation_key="y_coordinate", - unique_id_separator=" ", value_fn=lambda value: value[1], ), SmartThingsSensorEntityDescription( - key="Z Coordinate", + key="z_coordinate", translation_key="z_coordinate", - unique_id_separator=" ", value_fn=lambda value: value[2], ), ] @@ -903,6 +924,16 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.VERY_FINE_DUST_SENSOR: { + Attribute.VERY_FINE_DUST_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.VERY_FINE_DUST_LEVEL, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, Capability.VOLTAGE_MEASUREMENT: { Attribute.VOLTAGE: [ SmartThingsSensorEntityDescription( @@ -985,32 +1016,67 @@ async def async_setup_entry( ) -> None: """Add sensors for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsSensor( - entry_data.client, - device, - description, - entry_data.rooms, - capability, - attribute, - ) - for device in entry_data.devices.values() - for capability, attributes in CAPABILITY_TO_SENSORS.items() - if capability in device.status[MAIN] - for attribute, descriptions in attributes.items() - for description in descriptions - if ( - not description.capability_ignore_list - or not any( - all(capability in device.status[MAIN] for capability in capability_list) - for capability_list in description.capability_ignore_list - ) - ) - and ( - not description.exists_fn - or description.exists_fn(device.status[MAIN][capability][attribute]) - ) - ) + entities = [] + + entity_registry = er.async_get(hass) + + for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks + for capability, attributes in CAPABILITY_TO_SENSORS.items(): + if capability in device.status[MAIN]: + for attribute, descriptions in attributes.items(): + for description in descriptions: + if ( + not description.capability_ignore_list + or not any( + all( + capability in device.status[MAIN] + for capability in capability_list + ) + for capability_list in description.capability_ignore_list + ) + ) and ( + not description.exists_fn + or description.exists_fn( + device.status[MAIN][capability][attribute] + ) + ): + if ( + description.deprecated + and ( + reason := description.deprecated( + device.status[MAIN] + ) + ) + is not None + ): + if deprecate_entity( + hass, + entity_registry, + SENSOR_DOMAIN, + f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{description.key}", + f"deprecated_{reason}", + ): + entities.append( + SmartThingsSensor( + entry_data.client, + device, + description, + capability, + attribute, + ) + ) + continue + entities.append( + SmartThingsSensor( + entry_data.client, + device, + description, + capability, + attribute, + ) + ) + + async_add_entities(entities) class SmartThingsSensor(SmartThingsEntity, SensorEntity): @@ -1023,7 +1089,6 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsSensorEntityDescription, - rooms: dict[str, str], capability: Capability, attribute: Attribute, ) -> None: @@ -1031,8 +1096,8 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): capabilities_to_subscribe = {capability} if entity_description.use_temperature_unit: capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT) - super().__init__(client, device, rooms, capabilities_to_subscribe) - self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" + super().__init__(client, device, capabilities_to_subscribe) + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{entity_description.key}" self._attribute = attribute self.capability = capability self.entity_description = entity_description diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fdc905468f5..dfcaa094d1b 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -33,16 +33,86 @@ "acceleration": { "name": "Acceleration" }, + "door": { + "name": "[%key:component::binary_sensor::entity_component::door::name%]" + }, + "dryer_wrinkle_prevent_active": { + "name": "Wrinkle prevent active" + }, "filter_status": { "name": "Filter status" }, + "freezer_door": { + "name": "Freezer door" + }, + "cooler_door": { + "name": "Cooler door" + }, "remote_control": { "name": "Remote control" }, + "child_lock": { + "name": "Child lock" + }, "valve": { "name": "Valve" } }, + "button": { + "reset_water_filter": { + "name": "Reset water filter" + }, + "stop": { + "name": "[%key:common::action::stop%]" + } + }, + "event": { + "button": { + "state": { + "pushed": "Pushed", + "held": "Held", + "double": "Double", + "pushed_2x": "Pushed 2x", + "pushed_3x": "Pushed 3x", + "pushed_4x": "Pushed 4x", + "pushed_5x": "Pushed 5x", + "pushed_6x": "Pushed 6x", + "down": "Down", + "down_2x": "Down 2x", + "down_3x": "Down 3x", + "down_4x": "Down 4x", + "down_5x": "Down 5x", + "down_6x": "Down 6x", + "down_hold": "Down hold", + "up": "Up", + "up_2x": "Up 2x", + "up_3x": "Up 3x", + "up_4x": "Up 4x", + "up_5x": "Up 5x", + "up_6x": "Up 6x", + "up_hold": "Up hold", + "swipe_up": "Swipe up", + "swipe_down": "Swipe down", + "swipe_left": "Swipe left", + "swipe_right": "Swipe right" + } + } + }, + "number": { + "washer_rinse_cycles": { + "name": "Rinse cycles", + "unit_of_measurement": "cycles" + } + }, + "select": { + "operating_state": { + "state": { + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "pause": "[%key:common::state::paused%]", + "stop": "[%key:common::state::stopped%]" + } + } + }, "sensor": { "lighting_mode": { "name": "Activity lighting mode" @@ -84,7 +154,7 @@ "state": { "pause": "[%key:common::state::paused%]", "run": "Running", - "stop": "Stopped" + "stop": "[%key:common::state::stopped%]" } }, "dishwasher_job_state": { @@ -113,7 +183,7 @@ "state": { "pause": "[%key:common::state::paused%]", "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", - "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } }, "dryer_job_state": { @@ -278,6 +348,9 @@ "refrigeration_setpoint": { "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" }, + "brightness_intensity": { + "name": "Brightness intensity" + }, "robot_cleaner_cleaning_mode": { "name": "Cleaning mode", "state": { @@ -368,7 +441,7 @@ "state": { "pause": "[%key:common::state::paused%]", "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", - "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } }, "washer_job_state": { @@ -392,12 +465,59 @@ "freeze_protection": "Freeze protection" } } + }, + "switch": { + "bubble_soak": { + "name": "Bubble Soak" + }, + "wrinkle_prevent": { + "name": "Wrinkle prevent" + }, + "ice_maker": { + "name": "Ice maker" + } } }, "issues": { "deprecated_binary_valve": { - "title": "Deprecated valve binary sensor detected in some automations or scripts", - "description": "The valve binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." + "title": "Valve binary sensor deprecated", + "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. A valve entity with controls is available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_binary_valve_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_binary_valve::title%]", + "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts and disable the entity to fix this issue." + }, + "deprecated_binary_fridge_door": { + "title": "Refrigerator door binary sensor deprecated", + "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. Separate entities for cooler and freezer door are available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_binary_fridge_door_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_binary_fridge_door::title%]", + "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts and disable the entity to fix this issue." + }, + "deprecated_switch_appliance": { + "title": "Appliance switch deprecated", + "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nPlease update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_switch_appliance_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", + "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the entity to fix this issue." + }, + "deprecated_switch_media_player": { + "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", + "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." + }, + "deprecated_switch_media_player_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", + "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." + }, + "deprecated_media_player": { + "title": "Media player sensors deprecated", + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nPlease update your dashboards, templates to use the new media player entity and disable the entity to fix this issue." + }, + "deprecated_media_player_scripts": { + "title": "Deprecated sensor detected in some automations or scripts", + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new media player entity and disable the entity to fix this issue." } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index f470a90bb39..4e62957d3d4 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -2,17 +2,24 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any -from pysmartthings import Attribute, Capability, Command +from pysmartthings import Attribute, Capability, Command, SmartThings -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SmartThingsConfigEntry -from .const import MAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import INVALID_SWITCH_CATEGORIES, MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity CAPABILITIES = ( Capability.SWITCH_LEVEL, @@ -28,6 +35,58 @@ AC_CAPABILITIES = ( Capability.THERMOSTAT_COOLING_SETPOINT, ) +MEDIA_PLAYER_CAPABILITIES = ( + Capability.AUDIO_MUTE, + Capability.AUDIO_VOLUME, + Capability.MEDIA_PLAYBACK, +) + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsSwitchEntityDescription(SwitchEntityDescription): + """Describe a SmartThings switch entity.""" + + status_attribute: Attribute + component_translation_key: dict[str, str] | None = None + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsCommandSwitchEntityDescription(SmartThingsSwitchEntityDescription): + """Describe a SmartThings switch entity.""" + + command: Command + + +SWITCH = SmartThingsSwitchEntityDescription( + key=Capability.SWITCH, + status_attribute=Attribute.SWITCH, + name=None, +) +CAPABILITY_TO_COMMAND_SWITCHES: dict[ + Capability | str, SmartThingsCommandSwitchEntityDescription +] = { + Capability.CUSTOM_DRYER_WRINKLE_PREVENT: SmartThingsCommandSwitchEntityDescription( + key=Capability.CUSTOM_DRYER_WRINKLE_PREVENT, + translation_key="wrinkle_prevent", + status_attribute=Attribute.DRYER_WRINKLE_PREVENT, + command=Command.SET_DRYER_WRINKLE_PREVENT, + ) +} +CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescription] = { + Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK, + translation_key="bubble_soak", + status_attribute=Attribute.STATUS, + ), + Capability.SWITCH: SmartThingsSwitchEntityDescription( + key=Capability.SWITCH, + status_attribute=Attribute.SWITCH, + component_translation_key={ + "icemaker": "ice_maker", + }, + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -36,37 +95,152 @@ async def async_setup_entry( ) -> None: """Add switches for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsSwitch( - entry_data.client, device, entry_data.rooms, {Capability.SWITCH} + entities: list[SmartThingsEntity] = [ + SmartThingsCommandSwitch( + entry_data.client, + device, + description, + Capability(capability), ) for device in entry_data.devices.values() - if Capability.SWITCH in device.status[MAIN] - and not any(capability in device.status[MAIN] for capability in CAPABILITIES) - and not all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) + for capability, description in CAPABILITY_TO_COMMAND_SWITCHES.items() + if capability in device.status[MAIN] + ] + entities.extend( + SmartThingsSwitch( + entry_data.client, + device, + description, + Capability(capability), + component, + ) + for device in entry_data.devices.values() + for capability, description in CAPABILITY_TO_SWITCHES.items() + for component in device.status + if capability in device.status[component] + and ( + (description.component_translation_key is None and component == MAIN) + or ( + description.component_translation_key is not None + and component in description.component_translation_key + ) + ) ) + entity_registry = er.async_get(hass) + for device in entry_data.devices.values(): + if ( + Capability.SWITCH in device.status[MAIN] + and not any( + capability in device.status[MAIN] for capability in CAPABILITIES + ) + and not all( + capability in device.status[MAIN] for capability in AC_CAPABILITIES + ) + ): + media_player = all( + capability in device.status[MAIN] + for capability in MEDIA_PLAYER_CAPABILITIES + ) + appliance = ( + device.device.components[MAIN].manufacturer_category + in INVALID_SWITCH_CATEGORIES + ) + if media_player or appliance: + issue = "media_player" if media_player else "appliance" + if deprecate_entity( + hass, + entity_registry, + SWITCH_DOMAIN, + f"{device.device.device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + f"deprecated_switch_{issue}", + ): + entities.append( + SmartThingsSwitch( + entry_data.client, + device, + SWITCH, + Capability.SWITCH, + ) + ) + continue + entities.append( + SmartThingsSwitch( + entry_data.client, + device, + SWITCH, + Capability.SWITCH, + ) + ) + async_add_entities(entities) class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" - _attr_name = None + entity_description: SmartThingsSwitchEntityDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsSwitchEntityDescription, + capability: Capability, + component: str = MAIN, + ) -> None: + """Initialize the switch.""" + super().__init__(client, device, {capability}, component=component) + self.entity_description = entity_description + self.switch_capability = capability + self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{entity_description.status_attribute}_{entity_description.status_attribute}" + if ( + translation_keys := entity_description.component_translation_key + ) is not None and ( + translation_key := translation_keys.get(component) + ) is not None: + self._attr_translation_key = translation_key async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.execute_device_command( - Capability.SWITCH, + self.switch_capability, Command.OFF, ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.execute_device_command( - Capability.SWITCH, + self.switch_capability, Command.ON, ) @property def is_on(self) -> bool: - """Return true if light is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" + """Return true if switch is on.""" + return ( + self.get_attribute_value( + self.switch_capability, self.entity_description.status_attribute + ) + == "on" + ) + + +class SmartThingsCommandSwitch(SmartThingsSwitch): + """Define a SmartThings command switch.""" + + entity_description: SmartThingsCommandSwitchEntityDescription + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.execute_device_command( + self.switch_capability, + self.entity_description.command, + "off", + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.execute_device_command( + self.switch_capability, + self.entity_description.command, + "on", + ) diff --git a/homeassistant/components/smartthings/update.py b/homeassistant/components/smartthings/update.py new file mode 100644 index 00000000000..bb226918596 --- /dev/null +++ b/homeassistant/components/smartthings/update.py @@ -0,0 +1,87 @@ +"""Support for update entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import Any + +from awesomeversion import AwesomeVersion +from pysmartthings import Attribute, Capability, Command + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add update entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsUpdateEntity(entry_data.client, device, {Capability.FIRMWARE_UPDATE}) + for device in entry_data.devices.values() + if Capability.FIRMWARE_UPDATE in device.status[MAIN] + ) + + +def is_hex_version(version: str) -> bool: + """Check if the version is a hex version.""" + return len(version) == 8 and all(c in "0123456789abcdefABCDEF" for c in version) + + +class SmartThingsUpdateEntity(SmartThingsEntity, UpdateEntity): + """Define a SmartThings update entity.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + @property + def installed_version(self) -> str | None: + """Return the installed version of the entity.""" + return self.get_attribute_value( + Capability.FIRMWARE_UPDATE, Attribute.CURRENT_VERSION + ) + + @property + def latest_version(self) -> str | None: + """Return the available version of the entity.""" + return self.get_attribute_value( + Capability.FIRMWARE_UPDATE, Attribute.AVAILABLE_VERSION + ) + + @property + def in_progress(self) -> bool: + """Return if the entity is in progress.""" + return ( + self.get_attribute_value(Capability.FIRMWARE_UPDATE, Attribute.STATE) + == "updateInProgress" + ) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the firmware update.""" + await self.execute_device_command( + Capability.FIRMWARE_UPDATE, + Command.UPDATE_FIRMWARE, + ) + + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return if the latest version is newer.""" + if is_hex_version(latest_version): + latest_version = f"0x{latest_version}" + if is_hex_version(installed_version): + installed_version = f"0x{installed_version}" + return AwesomeVersion(latest_version) > AwesomeVersion(installed_version) diff --git a/homeassistant/components/smartthings/util.py b/homeassistant/components/smartthings/util.py new file mode 100644 index 00000000000..b21652ca629 --- /dev/null +++ b/homeassistant/components/smartthings/util.py @@ -0,0 +1,83 @@ +"""Utility functions for SmartThings integration.""" + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) + +from .const import DOMAIN + + +def deprecate_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + platform_domain: str, + entity_unique_id: str, + issue_string: str, +) -> bool: + """Create an issue for deprecated entities.""" + if entity_id := entity_registry.async_get_entity_id( + platform_domain, DOMAIN, entity_unique_id + ): + entity_entry = entity_registry.async_get(entity_id) + if not entity_entry: + return False + if entity_entry.disabled: + entity_registry.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + f"{issue_string}_{entity_id}", + ) + return False + translation_key = issue_string + placeholders = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or "Unknown", + } + if items := get_automations_and_scripts_using_entity(hass, entity_id): + translation_key = f"{translation_key}_scripts" + placeholders.update( + { + "items": "\n".join(items), + } + ) + async_create_issue( + hass, + DOMAIN, + f"{issue_string}_{entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + return True + return False + + +def get_automations_and_scripts_using_entity( + hass: HomeAssistant, + entity_id: str, +) -> list[str]: + """Get automations and scripts using an entity.""" + automations = automations_with_entity(hass, entity_id) + scripts = scripts_with_entity(hass, entity_id) + if not automations and not scripts: + return [] + + entity_reg = er.async_get(hass) + return [ + f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" + for integration, entities in ( + ("automation", automations), + ("script", scripts), + ) + for entity_id in entities + if (item := entity_reg.async_get(entity_id)) + ] diff --git a/homeassistant/components/smartthings/valve.py b/homeassistant/components/smartthings/valve.py index a38eb9e65c4..4279d528f8b 100644 --- a/homeassistant/components/smartthings/valve.py +++ b/homeassistant/components/smartthings/valve.py @@ -30,7 +30,7 @@ async def async_setup_entry( """Add valves for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsValve(entry_data.client, entry_data.rooms, device) + SmartThingsValve(entry_data.client, device) for device in entry_data.devices.values() if Capability.VALVE in device.status[MAIN] ) @@ -43,14 +43,12 @@ class SmartThingsValve(SmartThingsEntity, ValveEntity): _attr_reports_position = False _attr_name = None - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(client, device, rooms, {Capability.VALVE}) + super().__init__(client, device, {Capability.VALVE}) self._attr_device_class = DEVICE_CLASS_MAP.get( - device.device.components[0].user_category - or device.device.components[0].manufacturer_category + device.device.components[MAIN].user_category + or device.device.components[MAIN].manufacturer_category ) async def async_open_valve(self) -> None: diff --git a/homeassistant/components/smarty/config_flow.py b/homeassistant/components/smarty/config_flow.py index 9a55356a990..a7f0bdd4123 100644 --- a/homeassistant/components/smarty/config_flow.py +++ b/homeassistant/components/smarty/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Smarty integration.""" +import logging from typing import Any from pysmarty2 import Smarty @@ -10,6 +11,8 @@ from homeassistant.const import CONF_HOST, CONF_NAME from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class SmartyConfigFlow(ConfigFlow, domain=DOMAIN): """Smarty config flow.""" @@ -20,7 +23,8 @@ class SmartyConfigFlow(ConfigFlow, domain=DOMAIN): try: if smarty.update(): return None - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return "unknown" else: return "cannot_connect" diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index fc3af634764..89443fc7e27 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smhi", "iot_class": "cloud_polling", "loggers": ["pysmhi"], - "requirements": ["pysmhi==1.0.0"] + "requirements": ["pysmhi==1.0.1"] } diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 8f3e675ef6b..b3a6860e5b7 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pysmlight import Api2, Info, Radio +from pysmlight import Api2 from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant @@ -50,9 +50,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def get_radio(info: Info, idx: int) -> Radio: - """Get the radio object from the info.""" - assert info.radios is not None - return info.radios[idx] diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index ce3457ae81b..aaba15e19f2 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -22,6 +22,7 @@ from .const import SCAN_INTERNET_INTERVAL from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 0 SCAN_INTERVAL = SCAN_INTERNET_INTERVAL diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index 5caf43b7cba..f834392ea13 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -23,6 +23,8 @@ from .const import DOMAIN from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index fcfc364d983..ce4f8f43233 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -51,14 +51,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self.client = Api2(self._host, session=async_get_clientsession(self.hass)) try: - info = await self.client.get_info() - self._host = str(info.device_ip) - self._device_name = str(info.hostname) - - if info.model not in Devices: - return self.async_abort(reason="unsupported_device") - if not await self._async_check_auth_required(user_input): + info = await self.client.get_info() + self._host = str(info.device_ip) + self._device_name = str(info.hostname) + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + return await self._async_complete_entry(user_input) except SmlightConnectionError: errors["base"] = "cannot_connect" @@ -128,13 +128,13 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - info = await self.client.get_info() - - if info.model not in Devices: - return self.async_abort(reason="unsupported_device") - if not await self._async_check_auth_required(user_input): - return await self._async_complete_entry(user_input) + info = await self.client.get_info() + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + + return await self._async_complete_entry(user_input) except SmlightConnectionError: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3f527d1fcd9..e9025203b8c 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.2.3"], + "requirements": ["pysmlight==0.2.4"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 57a08d177d4..f045d009a00 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -25,6 +25,8 @@ from .const import UPTIME_DEVIATION from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class SmSensorEntityDescription(SensorEntityDescription): @@ -37,7 +39,7 @@ class SmSensorEntityDescription(SensorEntityDescription): class SmInfoEntityDescription(SensorEntityDescription): """Class describing SMLIGHT information entities.""" - value_fn: Callable[[Info], StateType] + value_fn: Callable[[Info, int], StateType] INFO: list[SmInfoEntityDescription] = [ @@ -46,24 +48,25 @@ INFO: list[SmInfoEntityDescription] = [ translation_key="device_mode", device_class=SensorDeviceClass.ENUM, options=["eth", "wifi", "usb"], - value_fn=lambda x: x.coord_mode, + value_fn=lambda x, idx: x.coord_mode, ), SmInfoEntityDescription( key="firmware_channel", translation_key="firmware_channel", device_class=SensorDeviceClass.ENUM, options=["dev", "release"], - value_fn=lambda x: x.fw_channel, - ), - SmInfoEntityDescription( - key="zigbee_type", - translation_key="zigbee_type", - device_class=SensorDeviceClass.ENUM, - options=["coordinator", "router", "thread"], - value_fn=lambda x: x.zb_type, + value_fn=lambda x, idx: x.fw_channel, ), ] +RADIO_INFO = SmInfoEntityDescription( + key="zigbee_type", + translation_key="zigbee_type", + device_class=SensorDeviceClass.ENUM, + options=["coordinator", "router", "thread"], + value_fn=lambda x, idx: x.radios[idx].zb_type, +) + SENSORS: list[SmSensorEntityDescription] = [ SmSensorEntityDescription( @@ -102,6 +105,16 @@ SENSORS: list[SmSensorEntityDescription] = [ ), ] +EXTRA_SENSOR = SmSensorEntityDescription( + key="zigbee_temperature_2", + translation_key="zigbee_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda x: x.zb_temp2, +) + UPTIME: list[SmSensorEntityDescription] = [ SmSensorEntityDescription( key="core_uptime", @@ -127,8 +140,7 @@ async def async_setup_entry( ) -> None: """Set up SMLIGHT sensor based on a config entry.""" coordinator = entry.runtime_data.data - - async_add_entities( + entities: list[SmEntity] = list( chain( (SmInfoSensorEntity(coordinator, description) for description in INFO), (SmSensorEntity(coordinator, description) for description in SENSORS), @@ -136,6 +148,16 @@ async def async_setup_entry( ) ) + entities.extend( + SmInfoSensorEntity(coordinator, RADIO_INFO, idx) + for idx, _ in enumerate(coordinator.data.info.radios) + ) + + if coordinator.data.sensors.zb_temp2 is not None: + entities.append(SmSensorEntity(coordinator, EXTRA_SENSOR)) + + async_add_entities(entities) + class SmSensorEntity(SmEntity, SensorEntity): """Representation of a slzb sensor.""" @@ -172,17 +194,20 @@ class SmInfoSensorEntity(SmEntity, SensorEntity): self, coordinator: SmDataUpdateCoordinator, description: SmInfoEntityDescription, + idx: int = 0, ) -> None: """Initiate slzb sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + self.idx = idx + sensor = f"_{idx}" if idx else "" + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}{sensor}" @property def native_value(self) -> StateType: """Return the sensor value.""" - value = self.entity_description.value_fn(self.coordinator.data.info) + value = self.entity_description.value_fn(self.coordinator.data.info, self.idx) options = self.entity_description.options if isinstance(value, int) and options is not None: diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 09d2714956c..5cd187c009c 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -22,6 +22,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 10d142e6221..48f9149645c 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -22,11 +22,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import get_radio from .const import LOGGER from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity +PARALLEL_UPDATES = 1 + def zigbee_latest_version(data: SmFwData, idx: int) -> Firmware | None: """Get the latest Zigbee firmware version.""" @@ -56,7 +57,7 @@ CORE_UPDATE_ENTITY = SmUpdateEntityDescription( ZB_UPDATE_ENTITY = SmUpdateEntityDescription( key="zigbee_update", translation_key="zigbee_update", - installed_version=lambda x, idx: get_radio(x, idx).zb_version, + installed_version=lambda x, idx: x.radios[idx].zb_version, latest_version=zigbee_latest_version, ) @@ -75,7 +76,6 @@ async def async_setup_entry( entities = [SmUpdateEntity(coordinator, CORE_UPDATE_ENTITY)] radios = coordinator.data.info.radios - assert radios is not None entities.extend( SmUpdateEntity(coordinator, ZB_UPDATE_ENTITY, idx) diff --git a/homeassistant/components/snoo/event.py b/homeassistant/components/snoo/event.py index 5932bfd9862..1e50ee46d90 100644 --- a/homeassistant/components/snoo/event.py +++ b/homeassistant/components/snoo/event.py @@ -31,6 +31,7 @@ async def async_setup_entry( "power", "status_requested", "sticky_white_noise_updated", + "config_change", ], ), ) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 0de1e6cf760..839382b2d84 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.3"] + "requirements": ["python-snoo==0.6.5"] } diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index f7cf6a4820b..1c86c066c7f 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -55,7 +55,8 @@ "activity": "Activity press", "power": "Power button pressed", "status_requested": "Status requested", - "sticky_white_noise_updated": "Sleepytime sounds updated" + "sticky_white_noise_updated": "Sleepytime sounds updated", + "config_change": "Config changed" } } } @@ -70,7 +71,7 @@ "level2": "Level 2", "level3": "Level 3", "level4": "Level 4", - "stop": "Stopped", + "stop": "[%key:common::state::stopped%]", "pretimeout": "Pre-timeout", "timeout": "Timeout" } @@ -88,7 +89,7 @@ "level2": "[%key:component::snoo::entity::sensor::state::state::level2%]", "level3": "[%key:component::snoo::entity::sensor::state::state::level3%]", "level4": "[%key:component::snoo::entity::sensor::state::state::level4%]", - "stop": "[%key:component::snoo::entity::sensor::state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } } }, diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json index 2b626987546..105a9282a6d 100644 --- a/homeassistant/components/solaredge/strings.json +++ b/homeassistant/components/solaredge/strings.json @@ -5,7 +5,7 @@ "title": "Define the API parameters for this installation", "data": { "name": "The name of this installation", - "site_id": "The SolarEdge site-id", + "site_id": "The SolarEdge site ID", "api_key": "[%key:common::config_flow::data::api_key%]" } } @@ -14,7 +14,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "site_not_active": "The site is not active", - "could_not_connect": "Could not connect to the solaredge API" + "could_not_connect": "Could not connect to the SolarEdge API" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" @@ -65,7 +65,7 @@ "name": "Grid power" }, "storage_power": { - "name": "Stored power" + "name": "Storage power" }, "purchased_energy": { "name": "Imported energy" diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index d99fa7793df..3478887d64c 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -41,7 +41,8 @@ class SpotifyFlowHandler( try: current_user = await spotify.get_current_user() - except Exception: # noqa: BLE001 + except Exception: + self.logger.exception("Error while connecting to Spotify") return self.async_abort(reason="connection_error") name = current_user.display_name diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index 90e573a1706..66d837c503f 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -13,7 +13,7 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "The Spotify integration is not configured. Please follow the documentation.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication.", + "reauth_account_mismatch": "The Spotify account authenticated with does not match the account that needed re-authentication.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 37b5dc2b647..e6a45390120 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.39", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.40", "sqlparse==0.5.0"] } diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 2853ad14217..31dd5b003b7 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -151,7 +151,8 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): if server.http_status == HTTPStatus.UNAUTHORIZED: return "invalid_auth" return "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unknown exception while validating connection") return "unknown" if "uuid" in status: diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 6e1fba8c3a3..93943b0a9ea 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.43.0"] + "requirements": ["async-upnp-client==0.44.0"] } diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index 2849e0e976a..1f3ef4ee4ba 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -18,4 +18,5 @@ async def async_get_config_entry_diagnostics( "advice": client.advice, "air_quality_index": client.lki, "windspeed_ms": client.windspeed_ms, + "forecast": await client.async_get_forecast(), } diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index d7304fa1238..a028f1f19c5 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -29,7 +29,7 @@ }, "issues": { "location_migration_failed": { - "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integrations uses.\n\nMake sure you are connected to the internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", + "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integration uses.\n\nMake sure you are connected to the Internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", "title": "Migration of your location failed" } }, diff --git a/homeassistant/components/sunweg/__init__.py b/homeassistant/components/sunweg/__init__.py index 86da0a247b1..0dfed0e6bb3 100644 --- a/homeassistant/components/sunweg/__init__.py +++ b/homeassistant/components/sunweg/__init__.py @@ -1,197 +1,39 @@ """The Sun WEG inverter sensor integration.""" -import datetime -import json -import logging - -from sunweg.api import APIHelper -from sunweg.plant import Plant - -from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.typing import StateType, UndefinedType -from homeassistant.util import Throttle +from homeassistant.helpers import issue_registry as ir -from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS, DeviceType - -SCAN_INTERVAL = datetime.timedelta(minutes=5) - -_LOGGER = logging.getLogger(__name__) +DOMAIN = "sunweg" -async def async_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: """Load the saved entities.""" - api = APIHelper(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) - if not await hass.async_add_executor_job(api.authenticate): - raise ConfigEntryAuthFailed("Username or Password may be incorrect!") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SunWEGData( - api, entry.data[CONF_PLANT_ID] + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "issue": "https://github.com/rokam/sunweg/issues/13", + "entries": "/config/integrations/integration/sunweg", + }, ) - 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.""" - hass.data[DOMAIN].pop(entry.entry_id) - if len(hass.data[DOMAIN]) == 0: - hass.data.pop(DOMAIN) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return True -class SunWEGData: - """The class for handling data retrieval.""" - - def __init__( - self, - api: APIHelper, - plant_id: int, - ) -> None: - """Initialize the probe.""" - - self.api = api - self.plant_id = plant_id - self.data: Plant = None - self.previous_values: dict = {} - - @Throttle(SCAN_INTERVAL) - def update(self) -> None: - """Update probe data.""" - _LOGGER.debug("Updating data for plant %s", self.plant_id) - try: - self.data = self.api.plant(self.plant_id) - for inverter in self.data.inverters: - self.api.complete_inverter(inverter) - except json.decoder.JSONDecodeError: - _LOGGER.error("Unable to fetch data from SunWEG server") - _LOGGER.debug("Finished updating data for plant %s", self.plant_id) - - def get_api_value( - self, - variable: str, - device_type: DeviceType, - inverter_id: int = 0, - deep_name: str | None = None, - ): - """Retrieve from a Plant the desired variable value.""" - if device_type == DeviceType.TOTAL: - return self.data.__dict__.get(variable) - - inverter_list = [i for i in self.data.inverters if i.id == inverter_id] - if len(inverter_list) == 0: - return None - inverter = inverter_list[0] - - if device_type == DeviceType.INVERTER: - return inverter.__dict__.get(variable) - if device_type == DeviceType.PHASE: - for phase in inverter.phases: - if phase.name == deep_name: - return phase.__dict__.get(variable) - elif device_type == DeviceType.STRING: - for mppt in inverter.mppts: - for string in mppt.strings: - if string.name == deep_name: - return string.__dict__.get(variable) - return None - - def get_data( - self, - *, - api_variable_key: str, - api_variable_unit: str | None, - deep_name: str | None, - device_type: DeviceType, - inverter_id: int, - name: str | UndefinedType | None, - native_unit_of_measurement: str | None, - never_resets: bool, - previous_value_drop_threshold: float | None, - ) -> tuple[StateType | datetime.datetime, str | None]: - """Get the data.""" - _LOGGER.debug( - "Data request for: %s", - name, - ) - variable = api_variable_key - previous_unit = native_unit_of_measurement - api_value = self.get_api_value(variable, device_type, inverter_id, deep_name) - previous_value = self.previous_values.get(variable) - return_value = api_value - if api_variable_unit is not None: - native_unit_of_measurement = self.get_api_value( - api_variable_unit, - device_type, - inverter_id, - deep_name, - ) - - # If we have a 'drop threshold' specified, then check it and correct if needed - if ( - previous_value_drop_threshold is not None - and previous_value is not None - and api_value is not None - and previous_unit == native_unit_of_measurement - ): - _LOGGER.debug( - ( - "%s - Drop threshold specified (%s), checking for drop... API" - " Value: %s, Previous Value: %s" - ), - name, - previous_value_drop_threshold, - api_value, - previous_value, - ) - diff = float(api_value) - float(previous_value) - - # Check if the value has dropped (negative value i.e. < 0) and it has only - # dropped by a small amount, if so, use the previous value. - # Note - The energy dashboard takes care of drops within 10% - # of the current value, however if the value is low e.g. 0.2 - # and drops by 0.1 it classes as a reset. - if -(previous_value_drop_threshold) <= diff < 0: - _LOGGER.debug( - ( - "Diff is negative, but only by a small amount therefore not a" - " nightly reset, using previous value (%s) instead of api value" - " (%s)" - ), - previous_value, - api_value, - ) - return_value = previous_value - else: - _LOGGER.debug("%s - No drop detected, using API value", name) - - # Lifetime total values should always be increasing, they will never reset, - # however the API sometimes returns 0 values when the clock turns to 00:00 - # local time in that scenario we should just return the previous value - # Scenarios: - # 1 - System has a genuine 0 value when it it first commissioned: - # - will return 0 until a non-zero value is registered - # 2 - System has been running fine but temporarily resets to 0 briefly - # at midnight: - # - will return the previous value - # 3 - HA is restarted during the midnight 'outage' - Not handled: - # - Previous value will not exist meaning 0 will be returned - # - This is an edge case that would be better handled by looking - # up the previous value of the entity from the recorder - if never_resets and api_value == 0 and previous_value: - _LOGGER.debug( - ( - "API value is 0, but this value should never reset, returning" - " previous value (%s) instead" - ), - previous_value, - ) - return_value = previous_value - - self.previous_values[variable] = return_value - - return (return_value, native_unit_of_measurement) +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/sunweg/config_flow.py b/homeassistant/components/sunweg/config_flow.py index 24df8c02f55..42535a9ef58 100644 --- a/homeassistant/components/sunweg/config_flow.py +++ b/homeassistant/components/sunweg/config_flow.py @@ -1,129 +1,11 @@ """Config flow for Sun WEG integration.""" -from collections.abc import Mapping -from typing import Any +from homeassistant.config_entries import ConfigFlow -from sunweg.api import APIHelper, SunWegApiError -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback - -from .const import CONF_PLANT_ID, DOMAIN +from . import DOMAIN class SunWEGConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow class.""" VERSION = 1 - - def __init__(self) -> None: - """Initialise sun weg server flow.""" - self.api: APIHelper = None - self.data: dict[str, Any] = {} - - @callback - def _async_show_user_form(self, step_id: str, errors=None) -> ConfigFlowResult: - """Show the form to the user.""" - default_username = "" - if CONF_USERNAME in self.data: - default_username = self.data[CONF_USERNAME] - data_schema = vol.Schema( - { - vol.Required(CONF_USERNAME, default=default_username): str, - vol.Required(CONF_PASSWORD): str, - } - ) - - return self.async_show_form( - step_id=step_id, data_schema=data_schema, errors=errors - ) - - def _set_auth_data( - self, step: str, username: str, password: str - ) -> ConfigFlowResult | None: - """Set username and password.""" - if self.api: - # Set username and password - self.api.username = username - self.api.password = password - else: - # Initialise the library with the username & password - self.api = APIHelper(username, password) - - try: - if not self.api.authenticate(): - return self._async_show_user_form(step, {"base": "invalid_auth"}) - except SunWegApiError: - return self._async_show_user_form(step, {"base": "timeout_connect"}) - - return None - - async def async_step_user(self, user_input=None) -> ConfigFlowResult: - """Handle the start of the config flow.""" - if not user_input: - return self._async_show_user_form("user") - - # Store authentication info - self.data = user_input - - conf_result = await self.hass.async_add_executor_job( - self._set_auth_data, - "user", - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) - - return await self.async_step_plant() if conf_result is None else conf_result - - async def async_step_plant(self, user_input=None) -> ConfigFlowResult: - """Handle adding a "plant" to Home Assistant.""" - plant_list = await self.hass.async_add_executor_job(self.api.listPlants) - - if len(plant_list) == 0: - return self.async_abort(reason="no_plants") - - plants = {plant.id: plant.name for plant in plant_list} - - if user_input is None and len(plant_list) > 1: - data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) - - return self.async_show_form(step_id="plant", data_schema=data_schema) - - if user_input is None and len(plant_list) == 1: - user_input = {CONF_PLANT_ID: plant_list[0].id} - - user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] - await self.async_set_unique_id(user_input[CONF_PLANT_ID]) - self._abort_if_unique_id_configured() - self.data.update(user_input) - return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Handle reauthorization request from SunWEG.""" - self.data.update(entry_data) - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle reauthorization flow.""" - if user_input is None: - return self._async_show_user_form("reauth_confirm") - - self.data.update(user_input) - conf_result = await self.hass.async_add_executor_job( - self._set_auth_data, - "reauth_confirm", - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) - if conf_result is not None: - return conf_result - - return self.async_update_reload_and_abort( - self._get_reauth_entry(), data=self.data - ) diff --git a/homeassistant/components/sunweg/const.py b/homeassistant/components/sunweg/const.py deleted file mode 100644 index 11d24352962..00000000000 --- a/homeassistant/components/sunweg/const.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Define constants for the Sun WEG component.""" - -from enum import Enum - -from homeassistant.const import Platform - - -class DeviceType(Enum): - """Device Type Enum.""" - - TOTAL = 1 - INVERTER = 2 - PHASE = 3 - STRING = 4 - - -CONF_PLANT_ID = "plant_id" - -DEFAULT_PLANT_ID = 0 - -DEFAULT_NAME = "Sun WEG" - -DOMAIN = "sunweg" - -PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json index 3ebe9ef8cb4..3e5c669f37f 100644 --- a/homeassistant/components/sunweg/manifest.json +++ b/homeassistant/components/sunweg/manifest.json @@ -1,10 +1,10 @@ { "domain": "sunweg", "name": "Sun WEG", - "codeowners": ["@rokam"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sunweg", "iot_class": "cloud_polling", - "loggers": ["sunweg"], - "requirements": ["sunweg==3.0.2"] + "loggers": [], + "requirements": [] } diff --git a/homeassistant/components/sunweg/sensor/__init__.py b/homeassistant/components/sunweg/sensor/__init__.py deleted file mode 100644 index f71d992bea9..00000000000 --- a/homeassistant/components/sunweg/sensor/__init__.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Read status of SunWEG inverters.""" - -from __future__ import annotations - -import logging -from types import MappingProxyType -from typing import Any - -from sunweg.api import APIHelper -from sunweg.device import Inverter -from sunweg.plant import Plant - -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .. import SunWEGData -from ..const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType -from .inverter import INVERTER_SENSOR_TYPES -from .phase import PHASE_SENSOR_TYPES -from .sensor_entity_description import SunWEGSensorEntityDescription -from .string import STRING_SENSOR_TYPES -from .total import TOTAL_SENSOR_TYPES - -_LOGGER = logging.getLogger(__name__) - - -def get_device_list( - api: APIHelper, config: MappingProxyType[str, Any] -) -> tuple[list[Inverter], int]: - """Retrieve the device list for the selected plant.""" - plant_id = int(config[CONF_PLANT_ID]) - - if plant_id == DEFAULT_PLANT_ID: - plant_info: list[Plant] = api.listPlants() - plant_id = plant_info[0].id - - devices: list[Inverter] = [] - # Get a list of devices for specified plant to add sensors for. - for inverter in api.plant(plant_id).inverters: - api.complete_inverter(inverter) - devices.append(inverter) - return (devices, plant_id) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the SunWEG sensor.""" - name = config_entry.data[CONF_NAME] - - probe: SunWEGData = hass.data[DOMAIN][config_entry.entry_id] - - devices, plant_id = await hass.async_add_executor_job( - get_device_list, probe.api, config_entry.data - ) - - entities = [ - SunWEGInverter( - probe, - name=f"{name} Total", - unique_id=f"{plant_id}-{description.key}", - description=description, - device_type=DeviceType.TOTAL, - ) - for description in TOTAL_SENSOR_TYPES - ] - - # Add sensors for each device in the specified plant. - entities.extend( - [ - SunWEGInverter( - probe, - name=f"{device.name}", - unique_id=f"{device.sn}-{description.key}", - description=description, - device_type=DeviceType.INVERTER, - inverter_id=device.id, - ) - for device in devices - for description in INVERTER_SENSOR_TYPES - ] - ) - - entities.extend( - [ - SunWEGInverter( - probe, - name=f"{device.name} {phase.name}", - unique_id=f"{device.sn}-{phase.name}-{description.key}", - description=description, - inverter_id=device.id, - device_type=DeviceType.PHASE, - deep_name=phase.name, - ) - for device in devices - for phase in device.phases - for description in PHASE_SENSOR_TYPES - ] - ) - - entities.extend( - [ - SunWEGInverter( - probe, - name=f"{device.name} {string.name}", - unique_id=f"{device.sn}-{string.name}-{description.key}", - description=description, - inverter_id=device.id, - device_type=DeviceType.STRING, - deep_name=string.name, - ) - for device in devices - for mppt in device.mppts - for string in mppt.strings - for description in STRING_SENSOR_TYPES - ] - ) - - async_add_entities(entities, True) - - -class SunWEGInverter(SensorEntity): - """Representation of a SunWEG Sensor.""" - - entity_description: SunWEGSensorEntityDescription - - def __init__( - self, - probe: SunWEGData, - name: str, - unique_id: str, - description: SunWEGSensorEntityDescription, - device_type: DeviceType, - inverter_id: int = 0, - deep_name: str | None = None, - ) -> None: - """Initialize a sensor.""" - self.probe = probe - self.entity_description = description - self.device_type = device_type - self.inverter_id = inverter_id - self.deep_name = deep_name - - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = unique_id - self._attr_icon = ( - description.icon if description.icon is not None else "mdi:solar-power" - ) - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(probe.plant_id))}, - manufacturer="SunWEG", - name=name, - ) - - def update(self) -> None: - """Get the latest data from the Sun WEG API and updates the state.""" - self.probe.update() - ( - self._attr_native_value, - self._attr_native_unit_of_measurement, - ) = self.probe.get_data( - api_variable_key=self.entity_description.api_variable_key, - api_variable_unit=self.entity_description.api_variable_unit, - deep_name=self.deep_name, - device_type=self.device_type, - inverter_id=self.inverter_id, - name=self.entity_description.name, - native_unit_of_measurement=self.native_unit_of_measurement, - never_resets=self.entity_description.never_resets, - previous_value_drop_threshold=self.entity_description.previous_value_drop_threshold, - ) diff --git a/homeassistant/components/sunweg/sensor/inverter.py b/homeassistant/components/sunweg/sensor/inverter.py deleted file mode 100644 index 1010488b38a..00000000000 --- a/homeassistant/components/sunweg/sensor/inverter.py +++ /dev/null @@ -1,70 +0,0 @@ -"""SunWEG Sensor definitions for the Inverter type.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import ( - UnitOfEnergy, - UnitOfFrequency, - UnitOfPower, - UnitOfTemperature, -) - -from .sensor_entity_description import SunWEGSensorEntityDescription - -INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="inverter_energy_today", - name="Energy today", - api_variable_key="_today_energy", - api_variable_unit="_today_energy_metric", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_energy_total", - name="Lifetime energy output", - api_variable_key="_total_energy", - api_variable_unit="_total_energy_metric", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - suggested_display_precision=1, - state_class=SensorStateClass.TOTAL, - never_resets=True, - ), - SunWEGSensorEntityDescription( - key="inverter_frequency", - name="AC frequency", - api_variable_key="_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_current_wattage", - name="Output power", - api_variable_key="_power", - api_variable_unit="_power_metric", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_temperature", - name="Temperature", - api_variable_key="_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:temperature-celsius", - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_power_factor", - name="Power Factor", - api_variable_key="_power_factor", - suggested_display_precision=1, - ), -) diff --git a/homeassistant/components/sunweg/sensor/phase.py b/homeassistant/components/sunweg/sensor/phase.py deleted file mode 100644 index d9db6c7c714..00000000000 --- a/homeassistant/components/sunweg/sensor/phase.py +++ /dev/null @@ -1,27 +0,0 @@ -"""SunWEG Sensor definitions for the Phase type.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential - -from .sensor_entity_description import SunWEGSensorEntityDescription - -PHASE_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="voltage", - name="Voltage", - api_variable_key="_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - suggested_display_precision=2, - ), - SunWEGSensorEntityDescription( - key="amperage", - name="Amperage", - api_variable_key="_amperage", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - suggested_display_precision=1, - ), -) diff --git a/homeassistant/components/sunweg/sensor/sensor_entity_description.py b/homeassistant/components/sunweg/sensor/sensor_entity_description.py deleted file mode 100644 index 8c792ab617f..00000000000 --- a/homeassistant/components/sunweg/sensor/sensor_entity_description.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Sensor Entity Description for the SunWEG integration.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from homeassistant.components.sensor import SensorEntityDescription - - -@dataclass(frozen=True) -class SunWEGRequiredKeysMixin: - """Mixin for required keys.""" - - api_variable_key: str - - -@dataclass(frozen=True) -class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): - """Describes SunWEG sensor entity.""" - - api_variable_unit: str | None = None - previous_value_drop_threshold: float | None = None - never_resets: bool = False - icon: str | None = None diff --git a/homeassistant/components/sunweg/sensor/string.py b/homeassistant/components/sunweg/sensor/string.py deleted file mode 100644 index ec59da5d20d..00000000000 --- a/homeassistant/components/sunweg/sensor/string.py +++ /dev/null @@ -1,27 +0,0 @@ -"""SunWEG Sensor definitions for the String type.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential - -from .sensor_entity_description import SunWEGSensorEntityDescription - -STRING_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="voltage", - name="Voltage", - api_variable_key="_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - suggested_display_precision=2, - ), - SunWEGSensorEntityDescription( - key="amperage", - name="Amperage", - api_variable_key="_amperage", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - suggested_display_precision=1, - ), -) diff --git a/homeassistant/components/sunweg/sensor/total.py b/homeassistant/components/sunweg/sensor/total.py deleted file mode 100644 index 2b94446a165..00000000000 --- a/homeassistant/components/sunweg/sensor/total.py +++ /dev/null @@ -1,50 +0,0 @@ -"""SunWEG Sensor definitions for Totals.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import UnitOfEnergy, UnitOfPower - -from .sensor_entity_description import SunWEGSensorEntityDescription - -TOTAL_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="total_money_total", - name="Money lifetime", - api_variable_key="_saving", - icon="mdi:cash", - native_unit_of_measurement="R$", - suggested_display_precision=2, - ), - SunWEGSensorEntityDescription( - key="total_energy_today", - name="Energy Today", - api_variable_key="_today_energy", - api_variable_unit="_today_energy_metric", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - SunWEGSensorEntityDescription( - key="total_output_power", - name="Output Power", - api_variable_key="_total_power", - native_unit_of_measurement=UnitOfPower.KILO_WATT, - device_class=SensorDeviceClass.POWER, - ), - SunWEGSensorEntityDescription( - key="total_energy_output", - name="Lifetime energy output", - api_variable_key="_total_energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - never_resets=True, - ), - SunWEGSensorEntityDescription( - key="last_update", - name="Last Update", - api_variable_key="_last_update", - device_class=SensorDeviceClass.DATE, - ), -) diff --git a/homeassistant/components/sunweg/strings.json b/homeassistant/components/sunweg/strings.json index 9ab7be053b1..75abf5d9271 100644 --- a/homeassistant/components/sunweg/strings.json +++ b/homeassistant/components/sunweg/strings.json @@ -1,35 +1,8 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_plants": "No plants have been found on this account", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" - }, - "step": { - "plant": { - "data": { - "plant_id": "Plant" - }, - "title": "Select your plant" - }, - "user": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "title": "Enter your Sun WEG information" - }, - "reauth_confirm": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "title": "[%key:common::config_flow::title::reauth%]" - } + "issues": { + "integration_removed": { + "title": "The SunWEG integration has been removed", + "description": "The SunWEG integration has been removed from Home Assistant.\n\nThe library that Home Assistant uses to connect with SunWEG services, [doesn't work as expected anymore, demanding daily token renew]({issue}).\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing SunWEG integration entries]({entries})." } } } diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 4dc6efc2e85..872044097d6 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -190,7 +190,7 @@ class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN): return "cannot_connect" except OpendataTransportError: return "bad_config" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error") return "unknown" return None diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 09bc157d4d2..73b7307aa2d 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -66,6 +66,12 @@ PLATFORMS_BY_TYPE = { SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH], SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.REMOTE.value: [Platform.SENSOR], + SupportedModels.ROLLER_SHADE.value: [ + Platform.COVER, + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], + SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -80,6 +86,7 @@ CLASS_BY_DEVICE = { SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt, SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch, SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch, + SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 16b41d75541..787c1fa720b 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -35,6 +35,8 @@ class SupportedModels(StrEnum): RELAY_SWITCH_1 = "relay_switch_1" LEAK = "leak" REMOTE = "remote" + ROLLER_SHADE = "roller_shade" + HUBMINI_MATTER = "hubmini_matter" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -51,6 +53,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.HUB2: SupportedModels.HUB2, SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM, SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1, + SwitchbotModel.ROLLER_SHADE: SupportedModels.ROLLER_SHADE, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -62,6 +65,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.LEAK: SupportedModels.LEAK, SwitchbotModel.REMOTE: SupportedModels.REMOTE, + SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER, } SUPPORTED_MODEL_TYPES = ( diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 3ef0f5625c2..bb73339aa05 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -37,6 +37,8 @@ async def async_setup_entry( coordinator = entry.runtime_data if isinstance(coordinator.device, switchbot.SwitchbotBlindTilt): async_add_entities([SwitchBotBlindTiltEntity(coordinator)]) + elif isinstance(coordinator.device, switchbot.SwitchbotRollerShade): + async_add_entities([SwitchBotRollerShadeEntity(coordinator)]) else: async_add_entities([SwitchBotCurtainEntity(coordinator)]) @@ -154,7 +156,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): ATTR_CURRENT_TILT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") - if (_tilt := self._attr_current_cover_position) is not None: + if (_tilt := self._attr_current_cover_tilt_position) is not None: self._attr_is_closed = (_tilt < self.CLOSED_DOWN_THRESHOLD) or ( _tilt > self.CLOSED_UP_THRESHOLD ) @@ -199,3 +201,85 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_opening = self.parsed_data["motionDirection"]["opening"] self._attr_is_closing = self.parsed_data["motionDirection"]["closing"] self.async_write_ha_state() + + +class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): + """Representation of a Switchbot.""" + + _device: switchbot.SwitchbotRollerShade + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + _attr_translation_key = "cover" + _attr_name = None + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the switchbot.""" + super().__init__(coordinator) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + return + + self._attr_current_cover_position = last_state.attributes.get( + ATTR_CURRENT_POSITION + ) + self._last_run_success = last_state.attributes.get("last_run_success") + if self._attr_current_cover_position is not None: + self._attr_is_closed = self._attr_current_cover_position <= 20 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the roller shade.""" + + _LOGGER.debug("Switchbot to open roller shade %s", self._address) + self._last_run_success = bool(await self._device.open()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the roller shade.""" + + _LOGGER.debug("Switchbot to close roller shade %s", self._address) + self._last_run_success = bool(await self._device.close()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the moving of roller shade.""" + + _LOGGER.debug("Switchbot to stop roller shade %s", self._address) + self._last_run_success = bool(await self._device.stop()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + + position = kwargs.get(ATTR_POSITION) + _LOGGER.debug("Switchbot to move at %d %s", position, self._address) + self._last_run_success = bool(await self._device.set_position(position)) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_closing = self._device.is_closing() + self._attr_is_opening = self._device.is_opening() + self._attr_current_cover_position = self.parsed_data["position"] + self._attr_is_closed = self.parsed_data["position"] <= 20 + + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py index 0a2c342ecf0..4b9a7e1b988 100644 --- a/homeassistant/components/switchbot/light.py +++ b/homeassistant/components/switchbot/light.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from switchbot import ColorMode as SwitchBotColorMode, SwitchbotBaseLight @@ -68,7 +68,9 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - brightness = round(kwargs.get(ATTR_BRIGHTNESS, self.brightness) / 255 * 100) + brightness = round( + cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness)) / 255 * 100 + ) if ( self.supported_color_modes diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 85d5bcf6436..3c68facf1e9 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.57.1"] + "requirements": ["PySwitchbot==0.59.0"] } diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 025c40bff9e..d68c913db15 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, @@ -71,9 +72,14 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.HUMIDITY, ), + "illuminance": SensorEntityDescription( + key="illuminance", + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ILLUMINANCE, + ), "temperature": SensorEntityDescription( key="temperature", - name=None, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index e380711303d..c3cf111199f 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -9,13 +9,21 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "username": "The email address used to sign in to the Switcher app.", + "token": "The local control token received from Switcher." } }, "reauth_confirm": { - "description": "Found a Switcher device that requires a token\nEnter your username and token\nFor more information see https://www.home-assistant.io/integrations/switcher_kis/#prerequisites", + "description": "[%key:component::switcher_kis::config::step::credentials::description%]", "data": { "username": "[%key:common::config_flow::data::username%]", "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "username": "[%key:component::switcher_kis::config::step::credentials::data_description::username%]", + "token": "[%key:component::switcher_kis::config::step::credentials::data_description::token%]" } } }, diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index 461ce9bfd3a..11c688eb9af 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/syncthru", "iot_class": "local_polling", "loggers": ["pysyncthru"], - "requirements": ["PySyncThru==0.8.0", "url-normalize==1.4.3"], + "requirements": ["PySyncThru==0.8.0", "url-normalize==2.2.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 1b26b7df84d..d9319beb595 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -9,7 +9,6 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import SynologyDSMNotLoggedInException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -31,15 +30,16 @@ from .const import ( from .coordinator import ( SynologyDSMCameraUpdateCoordinator, SynologyDSMCentralUpdateCoordinator, + SynologyDSMConfigEntry, + SynologyDSMData, SynologyDSMSwitchUpdateCoordinator, ) -from .models import SynologyDSMData from .service import async_setup_services _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) -> bool: """Set up Synology DSM sensors.""" # Migrate device identifiers @@ -120,13 +120,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SYNOLOGY_CONNECTION_EXCEPTIONS as ex: raise ConfigEntryNotReady from ex - synology_data = SynologyDSMData( + entry.runtime_data = SynologyDSMData( api=api, coordinator_central=coordinator_central, + coordinator_central_old_update_success=True, coordinator_cameras=coordinator_cameras, coordinator_switches=coordinator_switches, ) - hass.data.setdefault(DOMAIN, {})[entry.unique_id] = synology_data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -140,28 +140,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_state_change(async_notify_backup_listeners) ) + def async_check_last_update_success() -> None: + if ( + last := coordinator_central.last_update_success + ) is not entry.runtime_data.coordinator_central_old_update_success: + entry.runtime_data.coordinator_central_old_update_success = last + async_notify_backup_listeners() + + entry.runtime_data.coordinator_central.async_add_listener( + async_check_last_update_success + ) + return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SynologyDSMConfigEntry +) -> bool: """Unload Synology DSM sensors.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + entry_data = entry.runtime_data await entry_data.api.async_unload() - hass.data[DOMAIN].pop(entry.unique_id) return unload_ok -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: SynologyDSMConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) async def async_remove_config_entry_device( - hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: SynologyDSMConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove synology_dsm config entry from a device.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api assert api.information is not None serial = api.information.serial diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index c4b44542059..46e47ebde16 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -17,7 +17,6 @@ from homeassistant.components.backup import ( BackupNotFound, suggested_filename, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator from homeassistant.helpers.json import json_dumps @@ -29,7 +28,7 @@ from .const import ( DATA_BACKUP_AGENT_LISTENERS, DOMAIN, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -47,19 +46,19 @@ async def async_get_backup_agents( hass: HomeAssistant, ) -> list[BackupAgent]: """Return a list of backup agents.""" - if not ( - entries := hass.config_entries.async_loaded_entries(DOMAIN) - ) or not hass.data.get(DOMAIN): + entries: list[SynologyDSMConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + if not entries: LOGGER.debug("No proper config entry found") return [] - syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN] return [ SynologyDSMBackupAgent(hass, entry, entry.unique_id) for entry in entries if entry.unique_id is not None - and (syno_data := syno_datas.get(entry.unique_id)) - and syno_data.api.file_station + and entry.runtime_data.api.file_station and entry.options.get(CONF_BACKUP_PATH) + and entry.runtime_data.coordinator_central.last_update_success ] @@ -91,7 +90,9 @@ class SynologyDSMBackupAgent(BackupAgent): domain = DOMAIN - def __init__(self, hass: HomeAssistant, entry: ConfigEntry, unique_id: str) -> None: + def __init__( + self, hass: HomeAssistant, entry: SynologyDSMConfigEntry, unique_id: str + ) -> None: """Initialize the Synology DSM backup agent.""" super().__init__() LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id) @@ -100,7 +101,7 @@ class SynologyDSMBackupAgent(BackupAgent): self.path = ( f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}" ) - syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + syno_data = entry.runtime_data self.api = syno_data.api self.backup_base_names: dict[str, str] = {} diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 2f7d041cb10..1ae5fa90760 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -12,20 +12,17 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISKS, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi -from .const import DOMAIN -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -64,11 +61,11 @@ STORAGE_DISK_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ... async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS binary sensor.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api coordinator = data.coordinator_central assert api.storage is not None diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index 6512c370334..79297b1f1b4 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -12,7 +12,6 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -52,11 +51,11 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set buttons for device.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data async_add_entities(SynologyDSMButton(data.api, button) for button in BUTTONS) diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index acbcccb8894..f393b8efb55 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -16,7 +16,6 @@ from homeassistant.components.camera import ( CameraEntityDescription, CameraEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -29,9 +28,8 @@ from .const import ( DOMAIN, SIGNAL_CAMERA_SOURCE_CHANGED, ) -from .coordinator import SynologyDSMCameraUpdateCoordinator +from .coordinator import SynologyDSMCameraUpdateCoordinator, SynologyDSMConfigEntry from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -47,11 +45,11 @@ class SynologyDSMCameraEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS cameras.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data if coordinator := data.coordinator_cameras: async_add_entities( SynoDSMCamera(data.api, coordinator, camera_id) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 58784862305..f0da6f8fe47 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -72,7 +72,7 @@ from .const import ( DOMAIN, SYNOLOGY_CONNECTION_EXCEPTIONS, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry _LOGGER = logging.getLogger(__name__) @@ -131,7 +131,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SynologyDSMConfigEntry, ) -> SynologyDSMOptionsFlowHandler: """Get the options flow for this handler.""" return SynologyDSMOptionsFlowHandler() @@ -444,6 +444,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): class SynologyDSMOptionsFlowHandler(OptionsFlow): """Handle a option flow.""" + config_entry: SynologyDSMConfigEntry + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -451,7 +453,7 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) - syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.config_entry.unique_id] + syno_data = self.config_entry.runtime_data data_schema = vol.Schema( { diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 1b3e21090b8..dd97dedf65e 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, Concatenate @@ -28,6 +29,20 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +@dataclass +class SynologyDSMData: + """Data for the synology_dsm integration.""" + + api: SynoApi + coordinator_central: SynologyDSMCentralUpdateCoordinator + coordinator_central_old_update_success: bool + coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None + coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None + + +type SynologyDSMConfigEntry = ConfigEntry[SynologyDSMData] + + def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( func: Callable[Concatenate[_T, _P], Awaitable[_R]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]: @@ -57,12 +72,12 @@ def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator base class for synology_dsm.""" - config_entry: ConfigEntry + config_entry: SynologyDSMConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, update_interval: timedelta, ) -> None: @@ -85,7 +100,7 @@ class SynologyDSMSwitchUpdateCoordinator( def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for switch devices.""" @@ -116,7 +131,7 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for central device.""" @@ -136,7 +151,7 @@ class SynologyDSMCameraUpdateCoordinator( def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for cameras.""" diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py index b30955ae682..a673be23096 100644 --- a/homeassistant/components/synology_dsm/diagnostics.py +++ b/homeassistant/components/synology_dsm/diagnostics.py @@ -6,21 +6,20 @@ from typing import Any from homeassistant.components.camera import diagnostics as camera_diagnostics from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import CONF_DEVICE_TOKEN, DOMAIN -from .models import SynologyDSMData +from .const import CONF_DEVICE_TOKEN +from .coordinator import SynologyDSMConfigEntry TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_DEVICE_TOKEN} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SynologyDSMConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data syno_api = data.api dsm_info = syno_api.dsm.information diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index d35b262809c..6234f5e8dd0 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -2,6 +2,7 @@ from __future__ import annotations +from logging import getLogger import mimetypes from aiohttp import web @@ -22,7 +23,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import DOMAIN, SHARED_SUFFIX -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry, SynologyDSMData + +LOGGER = getLogger(__name__) async def async_get_media_source(hass: HomeAssistant) -> MediaSource: @@ -41,15 +44,13 @@ class SynologyPhotosMediaSourceIdentifier: """Split identifier into parts.""" parts = identifier.split("/") - self.unique_id = None + self.unique_id = parts[0] self.album_id = None self.cache_key = None self.file_name = None self.is_shared = False self.passphrase = "" - self.unique_id = parts[0] - if len(parts) > 1: album_parts = parts[1].split("_") self.album_id = album_parts[0] @@ -82,7 +83,7 @@ class SynologyPhotosMediaSource(MediaSource): item: MediaSourceItem, ) -> BrowseMediaSource: """Return media.""" - if not self.hass.data.get(DOMAIN): + if not self.hass.config_entries.async_loaded_entries(DOMAIN): raise BrowseError("Diskstation not initialized") return BrowseMediaSource( domain=DOMAIN, @@ -116,7 +117,13 @@ class SynologyPhotosMediaSource(MediaSource): for entry in self.entries ] identifier = SynologyPhotosMediaSourceIdentifier(item.identifier) - diskstation: SynologyDSMData = self.hass.data[DOMAIN][identifier.unique_id] + entry: SynologyDSMConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, identifier.unique_id + ) + ) + assert entry + diskstation = entry.runtime_data assert diskstation.api.photos is not None if identifier.album_id is None: @@ -244,7 +251,7 @@ class SynologyDsmMediaView(http.HomeAssistantView): self, request: web.Request, source_dir_id: str, location: str ) -> web.Response: """Start a GET request.""" - if not self.hass.data.get(DOMAIN): + if not self.hass.config_entries.async_loaded_entries(DOMAIN): raise web.HTTPNotFound # location: {cache_key}/{filename} cache_key, file_name, passphrase = location.split("/") @@ -257,7 +264,13 @@ class SynologyDsmMediaView(http.HomeAssistantView): if not isinstance(mime_type, str): raise web.HTTPNotFound - diskstation: SynologyDSMData = self.hass.data[DOMAIN][source_dir_id] + entry: SynologyDSMConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, source_dir_id + ) + ) + assert entry + diskstation = entry.runtime_data assert diskstation.api.photos is not None item = SynoPhotosItem(image_id, "", "", "", cache_key, "xl", shared, passphrase) try: diff --git a/homeassistant/components/synology_dsm/models.py b/homeassistant/components/synology_dsm/models.py deleted file mode 100644 index 4f51d329ded..00000000000 --- a/homeassistant/components/synology_dsm/models.py +++ /dev/null @@ -1,22 +0,0 @@ -"""The synology_dsm integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from .common import SynoApi -from .coordinator import ( - SynologyDSMCameraUpdateCoordinator, - SynologyDSMCentralUpdateCoordinator, - SynologyDSMSwitchUpdateCoordinator, -) - - -@dataclass -class SynologyDSMData: - """Data for the synology_dsm integration.""" - - api: SynoApi - coordinator_central: SynologyDSMCentralUpdateCoordinator - coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None - coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None diff --git a/homeassistant/components/synology_dsm/repairs.py b/homeassistant/components/synology_dsm/repairs.py index 725e77a2593..8a4e47a32b5 100644 --- a/homeassistant/components/synology_dsm/repairs.py +++ b/homeassistant/components/synology_dsm/repairs.py @@ -11,7 +11,6 @@ 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.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.selector import ( @@ -28,7 +27,7 @@ from .const import ( ISSUE_MISSING_BACKUP_SETUP, SYNOLOGY_CONNECTION_EXCEPTIONS, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -36,7 +35,7 @@ LOGGER = logging.getLogger(__name__) class MissingBackupSetupRepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" - def __init__(self, entry: ConfigEntry, issue_id: str) -> None: + def __init__(self, entry: SynologyDSMConfigEntry, issue_id: str) -> None: """Create flow.""" self.entry = entry self.issue_id = issue_id @@ -59,7 +58,7 @@ class MissingBackupSetupRepairFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" - syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.entry.unique_id] + syno_data = self.entry.runtime_data if user_input is not None: self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 2987de7a7c7..566885e3989 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DISKS, PERCENTAGE, @@ -31,14 +30,13 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import SynoApi -from .const import CONF_VOLUMES, DOMAIN, ENTITY_UNIT_LOAD -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .const import CONF_VOLUMES, ENTITY_UNIT_LOAD +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -287,11 +285,11 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS Sensor.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api coordinator = data.coordinator_central storage = api.storage diff --git a/homeassistant/components/synology_dsm/service.py b/homeassistant/components/synology_dsm/service.py index 366f7d4ba3a..40b6fd4bc30 100644 --- a/homeassistant/components/synology_dsm/service.py +++ b/homeassistant/components/synology_dsm/service.py @@ -3,13 +3,14 @@ from __future__ import annotations import logging +from typing import cast from synology_dsm.exceptions import SynologyDSMException from homeassistant.core import HomeAssistant, ServiceCall from .const import CONF_SERIAL, DOMAIN, SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -19,11 +20,20 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def service_handler(call: ServiceCall) -> None: """Handle service call.""" - serial = call.data.get(CONF_SERIAL) - dsm_devices = hass.data[DOMAIN] + serial: str | None = call.data.get(CONF_SERIAL) + entries: list[SynologyDSMConfigEntry] = ( + hass.config_entries.async_loaded_entries(DOMAIN) + ) + dsm_devices = { + cast(str, entry.unique_id): entry.runtime_data for entry in entries + } if serial: - dsm_device: SynologyDSMData = hass.data[DOMAIN][serial] + entry: SynologyDSMConfigEntry | None = ( + hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) + ) + assert entry + dsm_device = entry.runtime_data elif len(dsm_devices) == 1: dsm_device = next(iter(dsm_devices.values())) serial = next(iter(dsm_devices)) @@ -39,7 +49,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: return if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]: - if serial not in hass.data[DOMAIN]: + if serial not in dsm_devices: LOGGER.error("DSM with specified serial %s not found", serial) return LOGGER.debug("%s DSM with serial %s", call.service, serial) @@ -50,7 +60,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: ), call.service, ) - dsm_device = hass.data[DOMAIN][serial] + dsm_device = dsm_devices[serial] dsm_api = dsm_device.api try: await getattr(dsm_api, f"async_{call.service}")() diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index c4f1572ceea..91863ff3a26 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -9,16 +9,14 @@ from typing import Any from synology_dsm.api.surveillance_station import SynoSurveillanceStation from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN -from .coordinator import SynologyDSMSwitchUpdateCoordinator +from .coordinator import SynologyDSMConfigEntry, SynologyDSMSwitchUpdateCoordinator from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -41,11 +39,11 @@ SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS switch.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data if coordinator := data.coordinator_switches: assert coordinator.version is not None async_add_entities( diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index 71eed2d7f1f..3048a38cb9c 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -9,15 +9,12 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade from yarl import URL from homeassistant.components.update import UpdateEntity, UpdateEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -39,11 +36,11 @@ UPDATE_ENTITIES: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Synology DSM update entities.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data async_add_entities( SynoDSMUpdateEntity(data.api, data.coordinator_central, description) for description in UPDATE_ENTITIES diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 3bda29867cc..e1ee57e42b2 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -11,6 +11,7 @@ from systembridgeconnector.exceptions import ( AuthenticationException, ConnectionClosedException, ConnectionErrorException, + DataMissingException, ) from systembridgeconnector.version import Version from systembridgemodels.keyboard_key import KeyboardKey @@ -184,7 +185,7 @@ async def async_setup_entry( "host": entry.data[CONF_HOST], }, ) from exception - except TimeoutError as exception: + except (DataMissingException, TimeoutError) as exception: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="timeout", diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index 32507f6d84e..235d7e6b986 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -18,4 +18,6 @@ MODULES: Final[list[Module]] = [ Module.SYSTEM, ] -DATA_WAIT_TIMEOUT: Final[int] = 10 +DATA_WAIT_TIMEOUT: Final[int] = 20 + +GET_DATA_WAIT_TIMEOUT: Final[int] = 15 diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 1690bad4a4d..7e545f39e46 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -33,7 +33,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, MODULES +from .const import DOMAIN, GET_DATA_WAIT_TIMEOUT, MODULES from .data import SystemBridgeData @@ -119,7 +119,10 @@ class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData]) """Get data from WebSocket.""" await self.check_websocket_connected() - modules_data = await self.websocket_client.get_data(GetData(modules=modules)) + modules_data = await self.websocket_client.get_data( + GetData(modules=modules), + timeout=GET_DATA_WAIT_TIMEOUT, + ) # Merge new data with existing data for module in MODULES: diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index c7cae2f347b..d9226e7de6e 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -251,6 +251,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.GIGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=2, icon="mdi:speedometer", value=cpu_speed, ), @@ -261,6 +262,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, value=lambda data: data.cpu.temperature, ), SystemBridgeSensorEntityDescription( @@ -270,6 +272,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=2, value=lambda data: data.cpu.voltage, ), SystemBridgeSensorEntityDescription( @@ -284,6 +287,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, icon="mdi:memory", value=memory_free, ), @@ -291,6 +295,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( key="memory_used_percentage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:memory", value=lambda data: data.memory.virtual.percent, ), @@ -301,6 +306,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, icon="mdi:memory", value=memory_used, ), @@ -322,6 +328,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( translation_key="load", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, icon="mdi:percent", value=lambda data: data.cpu.usage, ), @@ -345,6 +352,7 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, value=lambda data: data.battery.percentage, ), SystemBridgeSensorEntityDescription( @@ -381,6 +389,7 @@ async def async_setup_entry( name=f"{partition.mount_point} space used", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:harddisk", value=( lambda data, @@ -457,6 +466,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:monitor", value=lambda data, k=index: display_refresh_rate(data, k), ), @@ -476,6 +486,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:speedometer", value=lambda data, k=index: gpu_core_clock_speed(data, k), ), @@ -490,6 +501,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:speedometer", value=lambda data, k=index: gpu_memory_clock_speed(data, k), ), @@ -503,6 +515,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=0, icon="mdi:memory", value=lambda data, k=index: gpu_memory_free(data, k), ), @@ -515,6 +528,7 @@ async def async_setup_entry( name=f"{gpu.name} memory used %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:memory", value=lambda data, k=index: gpu_memory_used_percentage(data, k), ), @@ -529,6 +543,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=0, icon="mdi:memory", value=lambda data, k=index: gpu_memory_used(data, k), ), @@ -569,6 +584,7 @@ async def async_setup_entry( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, value=lambda data, k=index: gpu_temperature(data, k), ), entry.data[CONF_PORT], @@ -580,6 +596,7 @@ async def async_setup_entry( name=f"{gpu.name} usage %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:percent", value=lambda data, k=index: gpu_usage_percentage(data, k), ), @@ -601,6 +618,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", + suggested_display_precision=2, value=lambda data, k=cpu.id: cpu_usage_per_cpu(data, k), ), entry.data[CONF_PORT], @@ -614,6 +632,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:chip", + suggested_display_precision=2, value=lambda data, k=cpu.id: cpu_power_per_cpu(data, k), ), entry.data[CONF_PORT], diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index bd16464b290..9302746aa17 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.1"], + "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.0.0"], "single_config_entry": true } diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index fb8a318ff45..134fe390357 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -48,13 +48,13 @@ "name": "Last boot" }, "load_15m": { - "name": "Load (15m)" + "name": "Load (15 min)" }, "load_1m": { - "name": "Load (1m)" + "name": "Load (1 min)" }, "load_5m": { - "name": "Load (5m)" + "name": "Load (5 min)" }, "memory_free": { "name": "Memory free" diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 4b0203acda3..d1994075f12 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -10,12 +10,17 @@ from PyTado.interface import Tado from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_MODE, @@ -56,23 +61,34 @@ type TadoConfigEntry = ConfigEntry[TadoData] async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: """Set up Tado from a config entry.""" + if CONF_REFRESH_TOKEN not in entry.data: + raise ConfigEntryAuthFailed _async_import_options_from_data_if_missing(hass, entry) _LOGGER.debug("Setting up Tado connection") + _LOGGER.debug( + "Creating tado instance with refresh token: %s", + entry.data[CONF_REFRESH_TOKEN], + ) + + def create_tado_instance() -> tuple[Tado, str]: + """Create a Tado instance, this time with a previously obtained refresh token.""" + tado = Tado(saved_refresh_token=entry.data[CONF_REFRESH_TOKEN]) + return tado, tado.device_activation_status() + try: - tado = await hass.async_add_executor_job( - Tado, - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - ) + tado, device_status = await hass.async_add_executor_job(create_tado_instance) except PyTado.exceptions.TadoWrongCredentialsException as err: raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err except PyTado.exceptions.TadoException as err: raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err - _LOGGER.debug( - "Tado connection established for username: %s", entry.data[CONF_USERNAME] - ) + if device_status != "COMPLETED": + raise ConfigEntryAuthFailed( + f"Device login flow status is {device_status}. Starting re-authentication." + ) + + _LOGGER.debug("Tado connection established") coordinator = TadoDataUpdateCoordinator(hass, entry, tado) await coordinator.async_config_entry_first_refresh() @@ -82,11 +98,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool entry.runtime_data = TadoData(coordinator, mobile_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True +async def async_migrate_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: + """Migrate old entry.""" + + if entry.version < 2: + _LOGGER.debug("Migrating Tado entry to version 2. Current data: %s", entry.data) + data = dict(entry.data) + data.pop(CONF_USERNAME, None) + data.pop(CONF_PASSWORD, None) + hass.config_entries.async_update_entry(entry=entry, data=data, version=2) + _LOGGER.debug("Migration to version 2 successful") + return True + + @callback def _async_import_options_from_data_if_missing( hass: HomeAssistant, entry: TadoConfigEntry @@ -106,11 +134,6 @@ def _async_import_options_from_data_if_missing( hass.config_entries.async_update_entry(entry, options=options) -async def update_listener(hass: HomeAssistant, entry: TadoConfigEntry): - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 6a2067ffff1..e6ae623d1fc 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -477,11 +477,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - # If the target temperature will be None - # if the device is performing an action - # that does not affect the temperature or - # the device is switching states - return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp + if self._current_tado_hvac_mode == CONST_MODE_OFF: + return TADO_DEFAULT_MIN_TEMP + return self._tado_zone_data.target_temp async def set_timer( self, diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index f251a292800..48c3d30cb2b 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -2,160 +2,176 @@ from __future__ import annotations +import asyncio +from collections.abc import Mapping import logging from typing import Any -import PyTado +from PyTado.exceptions import TadoException +from PyTado.http import DeviceActivationStatus from PyTado.interface import Tado -import requests.exceptions import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ( + SOURCE_REAUTH, ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.service_info.zeroconf import ( - ATTR_PROPERTIES_ID, - ZeroconfServiceInfo, -) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_OPTIONS, DOMAIN, - UNIQUE_ID, ) _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) - - -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - - try: - tado = await hass.async_add_executor_job( - Tado, data[CONF_USERNAME], data[CONF_PASSWORD] - ) - tado_me = await hass.async_add_executor_job(tado.get_me) - except KeyError as ex: - raise InvalidAuth from ex - except RuntimeError as ex: - raise CannotConnect from ex - except requests.exceptions.HTTPError as ex: - if ex.response.status_code > 400 and ex.response.status_code < 500: - raise InvalidAuth from ex - raise CannotConnect from ex - - if "homes" not in tado_me or len(tado_me["homes"]) == 0: - raise NoHomes - - home = tado_me["homes"][0] - unique_id = str(home["id"]) - name = home["name"] - - return {"title": name, UNIQUE_ID: unique_id} - class TadoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Tado.""" - VERSION = 1 + VERSION = 2 + login_task: asyncio.Task | None = None + refresh_token: str | None = None + tado: Tado | None = None + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth on credential failure.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare reauth.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + + return await self.async_step_user() async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" - errors = {} - if user_input is not None: + """Handle users reauth credentials.""" + + if self.tado is None: + _LOGGER.debug("Initiating device activation") try: - validated = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except NoHomes: - errors["base"] = "no_homes" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + self.tado = await self.hass.async_add_executor_job(Tado) + except TadoException: + _LOGGER.exception("Error while initiating Tado") + return self.async_abort(reason="cannot_connect") + assert self.tado is not None + tado_device_url = self.tado.device_verification_url() + user_code = URL(tado_device_url).query["user_code"] - if "base" not in errors: - await self.async_set_unique_id(validated[UNIQUE_ID]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=validated["title"], data=user_input - ) + async def _wait_for_login() -> None: + """Wait for the user to login.""" + assert self.tado is not None + _LOGGER.debug("Waiting for device activation") + try: + await self.hass.async_add_executor_job(self.tado.device_activation) + except Exception as ex: + _LOGGER.exception("Error while waiting for device activation") + raise CannotConnect from ex - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + if ( + self.tado.device_activation_status() + is not DeviceActivationStatus.COMPLETED + ): + raise CannotConnect + + _LOGGER.debug("Checking login task") + if self.login_task is None: + _LOGGER.debug("Creating task for device activation") + self.login_task = self.hass.async_create_task(_wait_for_login()) + + if self.login_task.done(): + _LOGGER.debug("Login task is done, checking results") + if self.login_task.exception(): + return self.async_show_progress_done(next_step_id="timeout") + self.refresh_token = await self.hass.async_add_executor_job( + self.tado.get_refresh_token + ) + return self.async_show_progress_done(next_step_id="finish_login") + + return self.async_show_progress( + step_id="user", + progress_action="wait_for_device", + description_placeholders={ + "url": tado_device_url, + "code": user_code, + }, + progress_task=self.login_task, ) + async def async_step_finish_login( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the finalization of reauth.""" + _LOGGER.debug("Finalizing reauth") + assert self.tado is not None + tado_me = await self.hass.async_add_executor_job(self.tado.get_me) + + if "homes" not in tado_me or len(tado_me["homes"]) == 0: + return self.async_abort(reason="no_homes") + + home = tado_me["homes"][0] + unique_id = str(home["id"]) + name = home["name"] + + if self.source != SOURCE_REAUTH: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=name, + data={CONF_REFRESH_TOKEN: self.refresh_token}, + ) + + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data={CONF_REFRESH_TOKEN: self.refresh_token}, + ) + + async def async_step_timeout( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle issues that need transition await from progress step.""" + if user_input is None: + return self.async_show_form( + step_id="timeout", + ) + del self.login_task + return await self.async_step_user() + async def async_step_homekit( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" - self._async_abort_entries_match() - properties = { - key.lower(): value for (key, value) in discovery_info.properties.items() - } - await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID]) - self._abort_if_unique_id_configured() - return await self.async_step_user() + await self._async_handle_discovery_without_unique_id() + return await self.async_step_homekit_confirm() - async def async_step_reconfigure( + async def async_step_homekit_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle a reconfiguration flow initialized by the user.""" - errors: dict[str, str] = {} - reconfigure_entry = self._get_reconfigure_entry() + """Prepare for Homekit.""" + if user_input is None: + return self.async_show_form(step_id="homekit_confirm") - if user_input is not None: - user_input[CONF_USERNAME] = reconfigure_entry.data[CONF_USERNAME] - try: - await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except PyTado.exceptions.TadoWrongCredentialsException: - errors["base"] = "invalid_auth" - except NoHomes: - errors["base"] = "no_homes" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - if not errors: - return self.async_update_reload_and_abort( - reconfigure_entry, data_updates=user_input - ) - - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema( - { - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors, - description_placeholders={ - CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME] - }, - ) + return await self.async_step_user() @staticmethod @callback @@ -173,8 +189,10 @@ class OptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(data=user_input) + if user_input: + result = self.async_create_entry(data=user_input) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + return result data_schema = vol.Schema( { @@ -191,11 +209,3 @@ class OptionsFlowHandler(OptionsFlow): class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class NoHomes(HomeAssistantError): - """Error to indicate the account has no homes.""" diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index bdc4bff1943..7720ff09110 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -37,6 +37,7 @@ TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = { # Configuration CONF_FALLBACK = "fallback" CONF_HOME_ID = "home_id" +CONF_REFRESH_TOKEN = "refresh_token" DATA = "data" # Weather diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 559bc4a16fb..5f3aa1de1e4 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -10,7 +10,6 @@ from PyTado.interface import Tado from requests import RequestException from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -20,6 +19,7 @@ if TYPE_CHECKING: from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, DOMAIN, INSIDE_TEMPERATURE_MEASUREMENT, @@ -58,8 +58,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): update_interval=SCAN_INTERVAL, ) self._tado = tado - self._username = config_entry.data[CONF_USERNAME] - self._password = config_entry.data[CONF_PASSWORD] + self._refresh_token = config_entry.data[CONF_REFRESH_TOKEN] self._fallback = config_entry.options.get( CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT ) @@ -108,6 +107,18 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): self.data["weather"] = home["weather"] self.data["geofence"] = home["geofence"] + refresh_token = await self.hass.async_add_executor_job( + self._tado.get_refresh_token + ) + + if refresh_token != self._refresh_token: + _LOGGER.debug("New refresh token obtained from Tado: %s", refresh_token) + self._refresh_token = refresh_token + self.hass.config_entries.async_update_entry( + self.config_entry, + data={**self.config_entry.data, CONF_REFRESH_TOKEN: refresh_token}, + ) + return self.data async def _async_update_devices(self) -> dict[str, dict]: diff --git a/homeassistant/components/tado/diagnostics.py b/homeassistant/components/tado/diagnostics.py new file mode 100644 index 00000000000..0426707c6a9 --- /dev/null +++ b/homeassistant/components/tado/diagnostics.py @@ -0,0 +1,20 @@ +"""Provides diagnostics for Tado.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import TadoConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: TadoConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a Tado config entry.""" + + return { + "data": config_entry.runtime_data.coordinator.data, + "mobile_devices": config_entry.runtime_data.mobile_coordinator.data, + } diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index b83e2695137..eba13d469f3 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.6"] + "requirements": ["python-tado==0.18.11"] } diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index ff1afc3c03d..53de3969998 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -1,33 +1,28 @@ { "config": { + "progress": { + "wait_for_device": "To authenticate, open the following URL and login at Tado:\n{url}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{code}```\n\n\nThe login attempt will time out after five minutes." + }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "could_not_authenticate": "Could not authenticate with Tado.", + "no_homes": "There are no homes linked to this Tado account.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "step": { - "user": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "title": "Connect to your Tado account" + "reauth_confirm": { + "title": "Authenticate with Tado", + "description": "You need to reauthenticate with Tado. Press `Submit` to start the authentication process." }, - "reconfigure": { - "title": "Reconfigure your Tado", - "description": "Reconfigure the entry for your account: `{username}`.", - "data": { - "password": "[%key:common::config_flow::data::password%]" - }, - "data_description": { - "password": "Enter the (new) password for Tado." - } + "homekit": { + "title": "Authenticate with Tado", + "description": "Your device has been discovered and needs to authenticate with Tado. Press `Submit` to start the authentication process." + }, + "timeout": { + "description": "The authentication process timed out. Please try again." } - }, - "error": { - "unknown": "[%key:common::config_flow::error::unknown%]", - "no_homes": "There are no homes linked to this Tado account.", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, "options": { diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 8796ae46ab7..b269eaaaf55 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -39,7 +39,7 @@ from homeassistant.helpers.selector import ( NumberSelectorConfig, ) -from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_TYPES +from .const import CONF_STATIONS, DEFAULT_RADIUS, DOMAIN async def async_get_nearby_stations( @@ -175,10 +175,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): vol.Required( CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") ): cv.string, - vol.Required( - CONF_FUEL_TYPES, - default=user_input.get(CONF_FUEL_TYPES, list(FUEL_TYPES)), - ): cv.multi_select(FUEL_TYPES), vol.Required( CONF_LOCATION, default=user_input.get( diff --git a/homeassistant/components/tankerkoenig/const.py b/homeassistant/components/tankerkoenig/const.py index c2a1dba9b6a..6761d20f4ce 100644 --- a/homeassistant/components/tankerkoenig/const.py +++ b/homeassistant/components/tankerkoenig/const.py @@ -3,14 +3,11 @@ DOMAIN = "tankerkoenig" NAME = "tankerkoenig" -CONF_FUEL_TYPES = "fuel_types" CONF_STATIONS = "stations" DEFAULT_RADIUS = 2 DEFAULT_SCAN_INTERVAL = 30 -FUEL_TYPES = {"e5": "Super", "e10": "Super E10", "diesel": "Diesel"} - ATTR_BRAND = "brand" ATTR_CITY = "city" ATTR_FUEL_TYPE = "fuel_type" diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 1f73d0577b3..f1e6bc8c865 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN +from .const import CONF_STATIONS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -54,7 +54,6 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf self._selected_stations: list[str] = self.config_entry.data[CONF_STATIONS] self.stations: dict[str, Station] = {} - self.fuel_types: list[str] = self.config_entry.data[CONF_FUEL_TYPES] self.show_on_map: bool = self.config_entry.options[CONF_SHOW_ON_MAP] self._tankerkoenig = Tankerkoenig( diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 29f4f439dd5..db620b2b11c 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -5,7 +5,6 @@ "data": { "name": "Region name", "api_key": "[%key:common::config_flow::data::api_key%]", - "fuel_types": "Fuel types", "location": "[%key:common::config_flow::data::location%]", "stations": "Additional fuel stations", "radius": "Search radius" diff --git a/homeassistant/components/tasmota/strings.json b/homeassistant/components/tasmota/strings.json index 22af3304297..13edee55110 100644 --- a/homeassistant/components/tasmota/strings.json +++ b/homeassistant/components/tasmota/strings.json @@ -20,11 +20,11 @@ "issues": { "topic_duplicated": { "title": "Several Tasmota devices are sharing the same topic", - "description": "Several Tasmota devices are sharing the topic {topic}.\n\n Tasmota devices with this problem: {offenders}." + "description": "Several Tasmota devices are sharing the topic {topic}.\n\nTasmota devices with this problem: {offenders}." }, "topic_no_prefix": { "title": "Tasmota device {name} has an invalid MQTT topic", - "description": "Tasmota device {name} with IP {ip} does not include `%prefix%` in its fulltopic.\n\nEntities for this devices are disabled until the configuration has been corrected." + "description": "Tasmota device {name} with IP {ip} does not include `%prefix%` in its FullTopic.\n\nEntities for this device are disabled until the configuration has been corrected." } } } diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index a01b889ef8f..6570d9c5428 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -41,7 +41,7 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( TedeeBinarySensorEntityDescription( key="semi_locked", translation_key="semi_locked", - is_on_fn=lambda lock: lock.state == TedeeLockState.HALF_OPEN, + is_on_fn=lambda lock: lock.state is TedeeLockState.HALF_OPEN, entity_category=EntityCategory.DIAGNOSTIC, ), TedeeBinarySensorEntityDescription( @@ -53,7 +53,10 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( TedeeBinarySensorEntityDescription( key="uncalibrated", translation_key="uncalibrated", - is_on_fn=lambda lock: lock.state == TedeeLockState.UNCALIBRATED, + is_on_fn=( + lambda lock: lock.state is TedeeLockState.UNCALIBRATED + or lock.state is TedeeLockState.UNKNOWN + ), device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 40206a5ccbb..208077a4153 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -214,7 +214,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore ), (CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 7a205446585..4ee8844d6e7 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -120,7 +120,8 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity): """Initialize the button.""" super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None - if action := config.get(CONF_PRESS): + # Scripts can be an empty list, therefore we need to check for None + if (action := config.get(CONF_PRESS)) is not None: self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 7a8e347ee8f..7c9c0ea9d53 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -172,7 +172,8 @@ class CoverTemplate(TemplateEntity, CoverEntity): (POSITION_ACTION, CoverEntityFeature.SET_POSITION), (TILT_ACTION, TILT_FEATURES), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 6e0f9fe5e0c..f3bc26391a9 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -157,7 +157,8 @@ class TemplateFan(TemplateEntity, FanEntity): CONF_SET_OSCILLATING_ACTION, CONF_SET_DIRECTION_ACTION, ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._state: bool | None = False diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 1cc47c74aa0..c58709eba5e 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -296,7 +296,8 @@ class LightTemplate(TemplateEntity, LightEntity): self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION) for action_id in (CONF_ON_ACTION, CONF_OFF_ACTION, CONF_EFFECT_ACTION): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._state = False @@ -323,7 +324,8 @@ class LightTemplate(TemplateEntity, LightEntity): (CONF_RGBW_ACTION, ColorMode.RGBW), (CONF_RGBWW_ACTION, ColorMode.RGBWW), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) color_modes.add(color_mode) self._supported_color_modes = filter_supported_color_modes(color_modes) @@ -333,7 +335,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._color_mode = next(iter(self._supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) - if self._action_scripts.get(CONF_EFFECT_ACTION): + if (self._action_scripts.get(CONF_EFFECT_ACTION)) is not None: self._attr_supported_features |= LightEntityFeature.EFFECT if self._supports_transition is True: self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index b19cadff26c..12a3e66cb5e 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -98,7 +98,8 @@ class TemplateLock(TemplateEntity, LockEntity): (CONF_UNLOCK, 0), (CONF_OPEN, LockEntityFeature.OPEN), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index eb60a3dbfe4..74d88ee96c4 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -141,7 +141,8 @@ class TemplateSelect(TemplateEntity, SelectEntity): super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None self._value_template = config[CONF_STATE] - if select_option := config.get(CONF_SELECT_OPTION): + # Scripts can be an empty list, therefore we need to check for None + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN) self._options_template = config[ATTR_OPTIONS] self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) @@ -197,7 +198,8 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) - if select_option := config.get(CONF_SELECT_OPTION): + # Scripts can be an empty list, therefore we need to check for None + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script( CONF_SELECT_OPTION, select_option, diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index b76fc28b83c..1d18ea9d5ca 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -120,7 +120,7 @@ def rewrite_legacy_to_modern_conf( return switches -def rewrite_options_to_moder_conf(option_config: dict[str, dict]) -> dict[str, dict]: +def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: """Rewrite option configuration to modern configuration.""" option_config = {**option_config} @@ -189,7 +189,7 @@ async def async_setup_entry( """Initialize config entry.""" _options = dict(config_entry.options) _options.pop("template_type") - _options = rewrite_options_to_moder_conf(_options) + _options = rewrite_options_to_modern_conf(_options) validated_config = SWITCH_CONFIG_SCHEMA(_options) async_add_entities([SwitchTemplate(hass, validated_config, config_entry.entry_id)]) @@ -199,7 +199,8 @@ def async_create_preview_switch( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> SwitchTemplate: """Create a preview switch.""" - validated_config = SWITCH_CONFIG_SCHEMA(config | {CONF_NAME: name}) + updated_config = rewrite_options_to_modern_conf(config) + validated_config = SWITCH_CONFIG_SCHEMA(updated_config | {CONF_NAME: name}) return SwitchTemplate(hass, validated_config, None) @@ -225,9 +226,10 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): assert name is not None self._template = config.get(CONF_STATE) - if on_action := config.get(CONF_TURN_ON): + # Scripts can be an empty list, therefore we need to check for None + if (on_action := config.get(CONF_TURN_ON)) is not None: self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) - if off_action := config.get(CONF_TURN_OFF): + if (off_action := config.get(CONF_TURN_OFF)) is not None: self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) self._state: bool | None = False diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index c4d41b52f31..1e18b06436a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -158,7 +158,8 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): (SERVICE_LOCATE, VacuumEntityFeature.LOCATE), (SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 7f597f1d9a8..86bab6f5ad1 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -135,6 +135,33 @@ WEATHER_SCHEMA = vol.Schema( PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the weather entities.""" + entities = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + + entities.append( + WeatherTemplate( + hass, + entity_conf, + unique_id, + ) + ) + + async_add_entities(entities) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -142,24 +169,32 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" - if discovery_info and "coordinator" in discovery_info: + if discovery_info is None: + config = rewrite_common_legacy_to_modern_conf(hass, config) + unique_id = config.get(CONF_UNIQUE_ID) + async_add_entities( + [ + WeatherTemplate( + hass, + config, + unique_id, + ) + ] + ) + return + + if "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(hass, config) - unique_id = config.get(CONF_UNIQUE_ID) - - async_add_entities( - [ - WeatherTemplate( - hass, - config, - unique_id, - ) - ] + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], ) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 27bfb9134ab..2642bd2f7d5 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -5,12 +5,7 @@ from typing import Final from aiohttp.client_exceptions import ClientResponseError import jwt -from tesla_fleet_api import ( - EnergySpecific, - TeslaFleetApi, - VehicleSigned, - VehicleSpecific, -) +from tesla_fleet_api import TeslaFleetApi from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( InvalidRegion, @@ -128,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - vehicles: list[TeslaFleetVehicleData] = [] energysites: list[TeslaFleetEnergyData] = [] for product in products: - if "vin" in product and hasattr(tesla, "vehicle"): + if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes: # Remove the protobuff 'cached_data' that we do not use to save memory product.pop("cached_data", None) vin = product["vin"] @@ -136,9 +131,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - if signing: if not tesla.private_key: await tesla.get_private_key(hass.config.path("tesla_fleet.key")) - api = VehicleSigned(tesla.vehicle, vin) + api = tesla.vehicles.createSigned(vin) else: - api = VehicleSpecific(tesla.vehicle, vin) + api = tesla.vehicles.createFleet(vin) coordinator = TeslaFleetVehicleDataCoordinator(hass, entry, api, product) await coordinator.async_config_entry_first_refresh() @@ -160,7 +155,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - signing=signing, ) ) - elif "energy_site_id" in product and hasattr(tesla, "energy"): + elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes: site_id = product["energy_site_id"] if not ( product["components"]["battery"] @@ -173,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - ) continue - api = EnergySpecific(tesla.energy, site_id) + api = tesla.energySites.create(site_id) live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, entry, api) history_coordinator = TeslaFleetEnergySiteHistoryCoordinator( @@ -227,7 +222,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - # Setup Platforms entry.runtime_data = TeslaFleetData(vehicles, energysites, scopes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 6f881d0feba..20d2d70b5dc 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -7,7 +7,6 @@ from random import randint from time import time from typing import TYPE_CHECKING, Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( InvalidToken, @@ -17,6 +16,7 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, VehicleOffline, ) +from tesla_fleet_api.tesla import EnergySite, VehicleFleet from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -27,7 +27,7 @@ if TYPE_CHECKING: from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState -VEHICLE_INTERVAL_SECONDS = 300 +VEHICLE_INTERVAL_SECONDS = 600 VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS) VEHICLE_WAIT = timedelta(minutes=15) @@ -70,7 +70,7 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: VehicleSpecific, + api: VehicleFleet, product: dict, ) -> None: """Initialize TeslaFleet Vehicle Update Coordinator.""" @@ -149,7 +149,7 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: EnergySpecific, + api: EnergySite, ) -> None: """Initialize TeslaFleet Energy Site Live coordinator.""" super().__init__( @@ -202,7 +202,7 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: EnergySpecific, + api: EnergySite, ) -> None: """Initialize Tesla Fleet Energy Site History coordinator.""" super().__init__( @@ -266,7 +266,7 @@ class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: EnergySpecific, + api: EnergySite, product: dict, ) -> None: """Initialize TeslaFleet Energy Info coordinator.""" diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index 0260acf368e..583e92595d0 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -3,8 +3,9 @@ from abc import abstractmethod from typing import Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.tesla.energysite import EnergySite +from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo @@ -41,7 +42,7 @@ class TeslaFleetEntity( | TeslaFleetEnergySiteLiveCoordinator | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator, - api: VehicleSpecific | EnergySpecific, + api: VehicleFleet | EnergySite, key: str, ) -> None: """Initialize common aspects of a TeslaFleet entity.""" diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 010197ccbd9..53c8e7d554c 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.13"] + "requirements": ["tesla-fleet-api==1.0.17"] } diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py index 469ebdca914..17a2bf50ed1 100644 --- a/homeassistant/components/tesla_fleet/models.py +++ b/homeassistant/components/tesla_fleet/models.py @@ -5,8 +5,8 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.tesla import EnergySite, VehicleFleet from homeassistant.helpers.device_registry import DeviceInfo @@ -31,7 +31,7 @@ class TeslaFleetData: class TeslaFleetVehicleData: """Data for a vehicle in the TeslaFleet integration.""" - api: VehicleSpecific + api: VehicleFleet coordinator: TeslaFleetVehicleDataCoordinator vin: str device: DeviceInfo @@ -43,7 +43,7 @@ class TeslaFleetVehicleData: class TeslaFleetEnergyData: """Data for a vehicle in the TeslaFleet integration.""" - api: EnergySpecific + api: EnergySite live_coordinator: TeslaFleetEnergySiteLiveCoordinator history_coordinator: TeslaFleetEnergySiteHistoryCoordinator info_coordinator: TeslaFleetEnergySiteInfoCoordinator diff --git a/homeassistant/components/tesla_fleet/number.py b/homeassistant/components/tesla_fleet/number.py index a1123ab9553..b4f7e42cafd 100644 --- a/homeassistant/components/tesla_fleet/number.py +++ b/homeassistant/components/tesla_fleet/number.py @@ -7,8 +7,8 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.tesla import EnergySite, VehicleFleet from homeassistant.components.number import ( NumberDeviceClass, @@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0 class TeslaFleetNumberVehicleEntityDescription(NumberEntityDescription): """Describes TeslaFleet Number entity.""" - func: Callable[[VehicleSpecific, float], Awaitable[Any]] + func: Callable[[VehicleFleet, float], Awaitable[Any]] native_min_value: float native_max_value: float min_key: str | None = None @@ -74,7 +74,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetNumberVehicleEntityDescription, ...] = ( class TeslaFleetNumberBatteryEntityDescription(NumberEntityDescription): """Describes TeslaFleet Number entity.""" - func: Callable[[EnergySpecific, float], Awaitable[Any]] + func: Callable[[EnergySite, float], Awaitable[Any]] requires: str | None = None diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 331885893fe..e4da161c63d 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -206,72 +206,72 @@ "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", - "off": "Off" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater front right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { "name": "Steering wheel heater", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "off": "[%key:common::state::off%]" } }, "components_customer_preferred_export_rule": { @@ -330,8 +330,8 @@ "state": { "starting": "Starting", "charging": "[%key:common::state::charging%]", - "disconnected": "Disconnected", - "stopped": "Stopped", + "disconnected": "[%key:common::state::disconnected%]", + "stopped": "[%key:common::state::stopped%]", "complete": "Complete", "no_power": "No power" } @@ -418,8 +418,8 @@ "name": "Grid Status", "state": { "island_status_unknown": "Unknown", - "on_grid": "Connected", - "off_grid": "Disconnected", + "on_grid": "[%key:common::state::connected%]", + "off_grid": "[%key:common::state::disconnected%]", "off_grid_unintentional": "Disconnected unintentionally", "off_grid_intentional": "Disconnected intentionally" } diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py index 614af8772cc..4c64acfafa6 100644 --- a/homeassistant/components/tesla_fleet/switch.py +++ b/homeassistant/components/tesla_fleet/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api.const import Scope, Seat +from tesla_fleet_api.const import AutoSeat, Scope, Seat from homeassistant.components.switch import ( SwitchDeviceClass, @@ -46,7 +46,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( ), TeslaFleetSwitchEntityDescription( key="climate_state_auto_seat_climate_left", - on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True), + on_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_LEFT, True + ), off_func=lambda api: api.remote_auto_seat_climate_request( Seat.FRONT_LEFT, False ), @@ -55,10 +57,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( TeslaFleetSwitchEntityDescription( key="climate_state_auto_seat_climate_right", on_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, True + AutoSeat.FRONT_RIGHT, True ), off_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, False + AutoSeat.FRONT_RIGHT, False ), scopes=[Scope.VEHICLE_CMDS], ), diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index eef974cc5a7..b820d2d1b43 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Callable from typing import Final -from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( Forbidden, @@ -12,6 +11,7 @@ from tesla_fleet_api.exceptions import ( SubscriptionRequired, TeslaFleetError, ) +from tesla_fleet_api.teslemetry import Teslemetry from teslemetry_stream import TeslemetryStream from homeassistant.config_entries import ConfigEntry @@ -111,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - # Remove the protobuff 'cached_data' that we do not use to save memory product.pop("cached_data", None) vin = product["vin"] - api = VehicleSpecific(teslemetry.vehicle, vin) + api = teslemetry.vehicles.create(vin) coordinator = TeslemetryVehicleDataCoordinator(hass, entry, api, product) device = DeviceInfo( identifiers={(DOMAIN, vin)}, @@ -156,7 +156,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) continue - api = EnergySpecific(teslemetry.energy, site_id) + api = teslemetry.energySites.create(site_id) device = DeviceInfo( identifiers={(DOMAIN, str(site_id))}, manufacturer="Tesla", diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 9d14df4501b..d0ba48d281e 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -293,7 +293,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( ), TeslemetryBinarySensorEntityDescription( key="dc_dc_enable", - streaming_key=Signal.DC_DC_ENABLE, + streaming_key=Signal.DCDC_ENABLE, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 86811131ab6..c1c8fcd2f73 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -6,9 +6,11 @@ from itertools import chain from typing import Any, cast from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.climate import ( ATTR_HVAC_MODE, + HVAC_MODES, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -22,15 +24,32 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .const import DOMAIN, TeslemetryClimateSide -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData DEFAULT_MIN_TEMP = 15 DEFAULT_MAX_TEMP = 28 +COP_TEMPERATURES = { + 30: CabinOverheatProtectionTemp.LOW, + 35: CabinOverheatProtectionTemp.MEDIUM, + 40: CabinOverheatProtectionTemp.HIGH, +} +PRESET_MODES = { + "Off": "off", + "On": "keep", + "Dog": "dog", + "Party": "camp", +} + PARALLEL_UPDATES = 0 @@ -45,13 +64,21 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryClimateEntity( + TeslemetryPollingClimateEntity( + vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) for vehicle in entry.runtime_data.vehicles ), ( - TeslemetryCabinOverheatProtectionEntity( + TeslemetryPollingCabinOverheatProtectionEntity( + vehicle, entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) for vehicle in entry.runtime_data.vehicles @@ -60,66 +87,22 @@ async def async_setup_entry( ) -class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): - """Telemetry vehicle climate entity.""" +class TeslemetryClimateEntity(TeslemetryRootEntity, ClimateEntity): + """Vehicle Climate Control.""" + + api: Vehicle _attr_precision = PRECISION_HALVES - _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] - _attr_supported_features = ( - ClimateEntityFeature.TURN_ON - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.PRESET_MODE - ) - _attr_preset_modes = ["off", "keep", "dog", "camp"] - - def __init__( - self, - data: TeslemetryVehicleData, - side: TeslemetryClimateSide, - scopes: Scope, - ) -> None: - """Initialize the climate.""" - self.scoped = Scope.VEHICLE_CMDS in scopes - - if not self.scoped: - self._attr_supported_features = ClimateEntityFeature(0) - self._attr_hvac_modes = [] - - super().__init__( - data, - side, - ) - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - value = self.get("climate_state_is_climate_on") - if value: - self._attr_hvac_mode = HVACMode.HEAT_COOL - else: - self._attr_hvac_mode = HVACMode.OFF - - # If not scoped, prevent the user from changing the HVAC mode by making it the only option - if self._attr_hvac_mode and not self.scoped: - self._attr_hvac_modes = [self._attr_hvac_mode] - - self._attr_current_temperature = self.get("climate_state_inside_temp") - self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting") - self._attr_preset_mode = self.get("climate_state_climate_keeper_mode") - self._attr_min_temp = cast( - float, self.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP) - ) - self._attr_max_temp = cast( - float, self.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP) - ) + _attr_preset_modes = list(PRESET_MODES.values()) + _attr_fan_modes = ["off", "bioweapon"] + _enable_turn_on_off_backwards_compatibility = False async def async_turn_on(self) -> None: """Set the climate state to on.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.auto_conditioning_start()) self._attr_hvac_mode = HVACMode.HEAT_COOL @@ -127,19 +110,21 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_turn_off(self) -> None: """Set the climate state to off.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.auto_conditioning_stop()) self._attr_hvac_mode = HVACMode.OFF self._attr_preset_mode = self._attr_preset_modes[0] + self._attr_fan_mode = self._attr_fan_modes[0] self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set the climate temperature.""" + if temp := kwargs.get(ATTR_TEMPERATURE): - await self.wake_up_if_asleep() + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command( self.api.set_temps( driver_temp=temp, @@ -163,18 +148,210 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the climate preset mode.""" - await self.wake_up_if_asleep() + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command( self.api.set_climate_keeper_mode( climate_keeper_mode=self._attr_preset_modes.index(preset_mode) ) ) self._attr_preset_mode = preset_mode - if preset_mode != self._attr_preset_modes[0]: - # Changing preset mode will also turn on climate + if preset_mode == self._attr_preset_modes[0]: + self._attr_hvac_mode = HVACMode.OFF + else: self._attr_hvac_mode = HVACMode.HEAT_COOL self.async_write_ha_state() + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the Bioweapon defense mode.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command( + self.api.set_bioweapon_mode( + on=(fan_mode != "off"), + manual_override=True, + ) + ) + self._attr_fan_mode = fan_mode + if fan_mode == self._attr_fan_modes[1]: + self._attr_hvac_mode = HVACMode.HEAT_COOL + self.async_write_ha_state() + + +class TeslemetryPollingClimateEntity(TeslemetryClimateEntity, TeslemetryVehicleEntity): + """Polling vehicle climate entity.""" + + _attr_supported_features = ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + ) + + def __init__( + self, + data: TeslemetryVehicleData, + side: TeslemetryClimateSide, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + + super().__init__(data, side) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + value = self.get("climate_state_is_climate_on") + if value is None: + self._attr_hvac_mode = None + if value: + self._attr_hvac_mode = HVACMode.HEAT_COOL + else: + self._attr_hvac_mode = HVACMode.OFF + + self._attr_current_temperature = self.get("climate_state_inside_temp") + self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting") + self._attr_preset_mode = self.get("climate_state_climate_keeper_mode") + if self.get("climate_state_bioweapon_mode"): + self._attr_fan_mode = "bioweapon" + else: + self._attr_fan_mode = "off" + self._attr_min_temp = cast( + float, self.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP) + ) + self._attr_max_temp = cast( + float, self.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP) + ) + + +class TeslemetryStreamingClimateEntity( + TeslemetryClimateEntity, TeslemetryVehicleStreamEntity, RestoreEntity +): + """Teslemetry steering wheel climate control.""" + + _attr_supported_features = ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + ) + + def __init__( + self, + data: TeslemetryVehicleData, + side: TeslemetryClimateSide, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + + # Initialize defaults + self._attr_hvac_mode = None + self._attr_current_temperature = None + self._attr_target_temperature = None + self._attr_fan_mode = None + self._attr_preset_mode = None + + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + self.side = side + super().__init__( + data, + side, + ) + + self._attr_min_temp = cast( + float, + data.coordinator.data.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP), + ) + self._attr_max_temp = cast( + float, + data.coordinator.data.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP), + ) + self.rhd: bool = data.coordinator.data.get("vehicle_config_rhd", False) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_hvac_mode = ( + HVACMode(state.state) if state.state in HVAC_MODES else None + ) + self._attr_current_temperature = state.attributes.get("current_temperature") + self._attr_target_temperature = state.attributes.get("temperature") + self._attr_preset_mode = state.attributes.get("preset_mode") + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_InsideTemp( + self._async_handle_inside_temp + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacACEnabled( + self._async_handle_hvac_ac_enabled + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_ClimateKeeperMode( + self._async_handle_climate_keeper_mode + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_RightHandDrive(self._async_handle_rhd) + ) + + if self.side == TeslemetryClimateSide.DRIVER: + if self.rhd: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacRightTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + else: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacLeftTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + elif self.side == TeslemetryClimateSide.PASSENGER: + if self.rhd: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacLeftTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + else: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacRightTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + + def _async_handle_inside_temp(self, data: float | None): + self._attr_current_temperature = data + self.async_write_ha_state() + + def _async_handle_hvac_ac_enabled(self, data: bool | None): + self._attr_hvac_mode = ( + None if data is None else HVACMode.HEAT_COOL if data else HVACMode.OFF + ) + self.async_write_ha_state() + + def _async_handle_climate_keeper_mode(self, data: str | None): + self._attr_preset_mode = PRESET_MODES.get(data) if data else None + self.async_write_ha_state() + + def _async_handle_hvac_temperature_request(self, data: float | None): + self._attr_target_temperature = data + self.async_write_ha_state() + + def _async_handle_rhd(self, data: bool | None): + if data is not None: + self.rhd = data + COP_MODES = { "Off": HVACMode.OFF, @@ -182,73 +359,27 @@ COP_MODES = { "FanOnly": HVACMode.FAN_ONLY, } -# String to celsius COP_LEVELS = { "Low": 30, "Medium": 35, "High": 40, } -# Celsius to IntEnum -TEMP_LEVELS = { - 30: CabinOverheatProtectionTemp.LOW, - 35: CabinOverheatProtectionTemp.MEDIUM, - 40: CabinOverheatProtectionTemp.HIGH, -} +class TeslemetryCabinOverheatProtectionEntity(TeslemetryRootEntity, ClimateEntity): + """Vehicle Cabin Overheat Protection.""" -class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEntity): - """Telemetry vehicle cabin overheat protection entity.""" + api: Vehicle _attr_precision = PRECISION_WHOLE _attr_target_temperature_step = 5 - _attr_min_temp = COP_LEVELS["Low"] - _attr_max_temp = COP_LEVELS["High"] + _attr_min_temp = 30 + _attr_max_temp = 40 _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(COP_MODES.values()) - _attr_entity_registry_enabled_default = False - def __init__( - self, - data: TeslemetryVehicleData, - scopes: Scope, - ) -> None: - """Initialize the climate.""" - - self.scoped = Scope.VEHICLE_CMDS in scopes - if self.scoped: - self._attr_supported_features = ( - ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF - ) - else: - self._attr_supported_features = ClimateEntityFeature(0) - self._attr_hvac_modes = [] - - super().__init__(data, "climate_state_cabin_overheat_protection") - - # Supported Features from data - if self.scoped and self.get("vehicle_config_cop_user_set_temp_supported"): - self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - - if (state := self.get("climate_state_cabin_overheat_protection")) is None: - self._attr_hvac_mode = None - else: - self._attr_hvac_mode = COP_MODES.get(state) - - # If not scoped, prevent the user from changing the HVAC mode by making it the only option - if self._attr_hvac_mode and not self.scoped: - self._attr_hvac_modes = [self._attr_hvac_mode] - - if (level := self.get("climate_state_cop_activation_temperature")) is None: - self._attr_target_temperature = None - else: - self._attr_target_temperature = COP_LEVELS.get(level) - - self._attr_current_temperature = self.get("climate_state_inside_temp") + _enable_turn_on_off_backwards_compatibility = False async def async_turn_on(self) -> None: """Set the climate state to on.""" @@ -260,26 +391,28 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn async def async_set_temperature(self, **kwargs: Any) -> None: """Set the climate temperature.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - if (temp := kwargs.get(ATTR_TEMPERATURE)) is None or ( - cop_mode := TEMP_LEVELS.get(temp) - ) is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_cop_temp", - ) + if temp := kwargs.get(ATTR_TEMPERATURE): + if (cop_mode := COP_TEMPERATURES.get(temp)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_cop_temp", + ) + self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.set_cop_temp(cop_mode)) - self._attr_target_temperature = temp + await handle_vehicle_command(self.api.set_cop_temp(cop_mode)) + self._attr_target_temperature = temp if mode := kwargs.get(ATTR_HVAC_MODE): - await self._async_set_cop(mode) + # Set HVAC mode will call write_ha_state + await self.async_set_hvac_mode(mode) + else: + self.async_write_ha_state() - self.async_write_ha_state() + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) - async def _async_set_cop(self, hvac_mode: HVACMode) -> None: if hvac_mode == HVACMode.OFF: await handle_vehicle_command( self.api.set_cabin_overheat_protection(on=False, fan_only=False) @@ -294,10 +427,125 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn ) self._attr_hvac_mode = hvac_mode - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set the climate mode and state.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await self._async_set_cop(hvac_mode) + self.async_write_ha_state() + + +class TeslemetryPollingCabinOverheatProtectionEntity( + TeslemetryVehicleEntity, TeslemetryCabinOverheatProtectionEntity +): + """Vehicle Cabin Overheat Protection.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + + super().__init__( + data, + "climate_state_cabin_overheat_protection", + ) + + # Supported Features + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + ) + if self.get("vehicle_config_cop_user_set_temp_supported"): + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + + # Scopes + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + if (state := self.get("climate_state_cabin_overheat_protection")) is None: + self._attr_hvac_mode = None + else: + self._attr_hvac_mode = COP_MODES.get(state) + + if (level := self.get("climate_state_cop_activation_temperature")) is None: + self._attr_target_temperature = None + else: + self._attr_target_temperature = COP_LEVELS.get(level) + + self._attr_current_temperature = self.get("climate_state_inside_temp") + + +class TeslemetryStreamingCabinOverheatProtectionEntity( + TeslemetryVehicleStreamEntity, + TeslemetryCabinOverheatProtectionEntity, + RestoreEntity, +): + """Vehicle Cabin Overheat Protection.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + + # Initialize defaults + self._attr_hvac_mode = None + self._attr_current_temperature = None + self._attr_target_temperature = None + self._attr_fan_mode = None + self._attr_preset_mode = None + + super().__init__(data, "climate_state_cabin_overheat_protection") + + # Supported Features + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + ) + if data.coordinator.data.get("vehicle_config_cop_user_set_temp_supported"): + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + + # Scopes + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_hvac_mode = ( + HVACMode(state.state) if state.state in HVAC_MODES else None + ) + self._attr_current_temperature = state.attributes.get("temperature") + self._attr_target_temperature = state.attributes.get("target_temperature") + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_InsideTemp( + self._async_handle_inside_temp + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_CabinOverheatProtectionMode( + self._async_handle_protection_mode + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_CabinOverheatProtectionTemperatureLimit( + self._async_handle_temperature_limit + ) + ) + + def _async_handle_inside_temp(self, value: float | None): + self._attr_current_temperature = value + self.async_write_ha_state() + + def _async_handle_protection_mode(self, value: str | None): + self._attr_hvac_mode = COP_MODES.get(value) if value is not None else None + self.async_write_ha_state() + + def _async_handle_temperature_limit(self, value: str | None): + self._attr_target_temperature = ( + COP_LEVELS.get(value) if value is not None else None + ) self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index d8cf2bd7945..a25a98d6c68 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -6,12 +6,12 @@ from collections.abc import Mapping from typing import Any from aiohttp import ClientConnectionError -from tesla_fleet_api import Teslemetry from tesla_fleet_api.exceptions import ( InvalidToken, SubscriptionRequired, TeslaFleetError, ) +from tesla_fleet_api.teslemetry import Teslemetry import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index f902fb4cc1b..07549008a6c 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -5,13 +5,13 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( InvalidToken, SubscriptionRequired, TeslaFleetError, ) +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -49,7 +49,7 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: VehicleSpecific, + api: Vehicle, product: dict, ) -> None: """Initialize Teslemetry Vehicle Update Coordinator.""" @@ -87,7 +87,7 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: EnergySpecific, + api: EnergySite, data: dict, ) -> None: """Initialize Teslemetry Energy Site Live coordinator.""" @@ -133,7 +133,7 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: EnergySpecific, + api: EnergySite, product: dict, ) -> None: """Initialize Teslemetry Energy Info coordinator.""" @@ -169,7 +169,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: EnergySpecific, + api: EnergySite, ) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index 82d3db123c3..3d145d24b0c 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -4,8 +4,8 @@ from abc import abstractmethod from typing import Any from propcache.api import cached_property -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import Signal from homeassistant.exceptions import ServiceValidationError @@ -29,7 +29,7 @@ class TeslemetryRootEntity(Entity): _attr_has_entity_name = True scoped: bool - api: VehicleSpecific | EnergySpecific + api: Vehicle | EnergySite def raise_for_scope(self, scope: Scope): """Raise an error if a scope is not available.""" @@ -105,7 +105,7 @@ class TeslemetryVehicleEntity(TeslemetryEntity): """Parent class for Teslemetry Vehicle entities.""" _last_update: int = 0 - api: VehicleSpecific + api: Vehicle vehicle: TeslemetryVehicleData def __init__( @@ -134,7 +134,7 @@ class TeslemetryVehicleEntity(TeslemetryEntity): class TeslemetryEnergyLiveEntity(TeslemetryEntity): """Parent class for Teslemetry Energy Site Live entities.""" - api: EnergySpecific + api: EnergySite def __init__( self, @@ -155,7 +155,7 @@ class TeslemetryEnergyLiveEntity(TeslemetryEntity): class TeslemetryEnergyInfoEntity(TeslemetryEntity): """Parent class for Teslemetry Energy Site Info Entities.""" - api: EnergySpecific + api: EnergySite def __init__( self, @@ -194,7 +194,7 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): """Parent class for Teslemetry Wall Connector Entities.""" _attr_has_entity_name = True - api: EnergySpecific + api: EnergySite def __init__( self, diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 3d37ced8cff..4c21bb017d8 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.13", "teslemetry-stream==0.6.12"] + "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.7.1"] } diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index 409b409e325..50f15618e66 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -2,8 +2,8 @@ from __future__ import annotations -from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -62,7 +62,7 @@ async def async_setup_entry( class TeslemetryMediaEntity(TeslemetryRootEntity, MediaPlayerEntity): """Base vehicle media player class.""" - api: VehicleSpecific + api: Vehicle _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_volume_step = VOLUME_STEP diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 5b78386c68a..fd6cf12b5b9 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -6,8 +6,8 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle from homeassistant.config_entries import ConfigEntry @@ -34,7 +34,7 @@ class TeslemetryData: class TeslemetryVehicleData: """Data for a vehicle in the Teslemetry integration.""" - api: VehicleSpecific + api: Vehicle config_entry: ConfigEntry coordinator: TeslemetryVehicleDataCoordinator stream: TeslemetryStream @@ -50,7 +50,7 @@ class TeslemetryVehicleData: class TeslemetryEnergyData: """Data for a vehicle in the Teslemetry integration.""" - api: EnergySpecific + api: EnergySite live_coordinator: TeslemetryEnergySiteLiveCoordinator | None info_coordinator: TeslemetryEnergySiteInfoCoordinator history_coordinator: TeslemetryEnergyHistoryCoordinator | None diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 10c15a68b09..ff25dec59b8 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -7,8 +7,8 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.number import ( @@ -46,7 +46,7 @@ PARALLEL_UPDATES = 0 class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription): """Describes Teslemetry Number entity.""" - func: Callable[[VehicleSpecific, int], Awaitable[Any]] + func: Callable[[Vehicle, int], Awaitable[Any]] min_key: str | None = None max_key: str native_min_value: float @@ -99,7 +99,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( class TeslemetryNumberBatteryEntityDescription(NumberEntityDescription): """Describes Teslemetry Number entity.""" - func: Callable[[EnergySpecific, float], Awaitable[Any]] + func: Callable[[EnergySite, float], Awaitable[Any]] requires: str | None = None scopes: list[Scope] diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 0d268e302de..9e13d15edc4 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -7,8 +7,8 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat +from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -40,7 +40,7 @@ LEVEL = {OFF: 0, LOW: 1, MEDIUM: 2, HIGH: 3} class TeslemetrySelectEntityDescription(SelectEntityDescription): """Seat Heater entity description.""" - select_fn: Callable[[VehicleSpecific, int], Awaitable[Any]] + select_fn: Callable[[Vehicle, int], Awaitable[Any]] supported_fn: Callable[[dict], bool] = lambda _: True streaming_listener: ( Callable[ diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index b1c6b487bf9..1ba4536ac2b 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -124,6 +124,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_voltage", polling=True, + streaming_key=Signal.CHARGER_VOLTAGE, + streaming_firmware="2024.44.32", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index c1df7d5aa57..4ff78781c7f 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Account is already configured", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_account_mismatch": "The reauthentication account does not match the original account" }, @@ -226,6 +226,12 @@ "dog": "Dog mode", "camp": "Camp mode" } + }, + "fan_mode": { + "state": { + "off": "[%key:common::state::off%]", + "bioweapon": "Bioweapon defense" + } } } } @@ -256,72 +262,72 @@ "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", - "off": "Off" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater front right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { "name": "Steering wheel heater", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "off": "[%key:common::state::off%]" } }, "components_customer_preferred_export_rule": { @@ -416,8 +422,8 @@ "state": { "starting": "Starting", "charging": "[%key:common::state::charging%]", - "disconnected": "Disconnected", - "stopped": "Stopped", + "disconnected": "[%key:common::state::disconnected%]", + "stopped": "[%key:common::state::stopped%]", "complete": "Complete", "no_power": "No power" } @@ -528,7 +534,7 @@ "vin": { "name": "Vehicle", "state": { - "disconnected": "Disconnected" + "disconnected": "[%key:common::state::disconnected%]" } }, "vpp_backup_reserve_percent": { @@ -720,7 +726,7 @@ }, "enable": { "description": "Enable or disable scheduled charging.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "time": { "description": "Time to start charging.", @@ -742,7 +748,7 @@ }, "enable": { "description": "Enable or disable scheduled departure.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "end_off_peak_time": { "description": "Time to complete charging by.", @@ -776,7 +782,7 @@ }, "enable": { "description": "Enable or disable speed limit.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "pin": { "description": "4 digit PIN.", @@ -808,7 +814,7 @@ }, "enable": { "description": "Enable or disable valet mode.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "pin": { "description": "4 digit PIN.", diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 516a6f9852f..645a8398820 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api.const import Scope +from tesla_fleet_api.const import AutoSeat, Scope from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.switch import ( @@ -62,15 +62,23 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_left", streaming_listener=lambda x, y: x.listen_AutoSeatClimateLeft(y), - on_func=lambda api: api.remote_auto_seat_climate_request(1, True), - off_func=lambda api: api.remote_auto_seat_climate_request(1, False), + on_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_LEFT, True + ), + off_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_LEFT, False + ), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_right", streaming_listener=lambda x, y: x.listen_AutoSeatClimateRight(y), - on_func=lambda api: api.remote_auto_seat_climate_request(2, True), - off_func=lambda api: api.remote_auto_seat_climate_request(2, False), + on_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_RIGHT, True + ), + off_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_RIGHT, False + ), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 0b0255508e0..b8d40877de4 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any from tesla_fleet_api.const import Scope -from tesla_fleet_api.vehiclespecific import VehicleSpecific +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.core import HomeAssistant @@ -48,7 +48,7 @@ async def async_setup_entry( class TeslemetryUpdateEntity(TeslemetryRootEntity, UpdateEntity): """Teslemetry Updates entity.""" - api: VehicleSpecific + api: Vehicle _attr_supported_features = UpdateEntityFeature.PROGRESS async def async_install( diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index f73ecc7a729..e247931e3ba 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -5,9 +5,9 @@ from http import HTTPStatus import logging from aiohttp import ClientError, ClientResponseError -from tesla_fleet_api import EnergySpecific, Tessie from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import TeslaFleetError +from tesla_fleet_api.tessie import Tessie from tessie_api import get_state_of_all_vehicles from homeassistant.config_entries import ConfigEntry @@ -123,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo ) continue - api = EnergySpecific(tessie.energy, site_id) + api = tessie.energySites.create(site_id) energysites.append( TessieEnergyData( api=api, diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index b06fe6123a5..2382595b058 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -8,8 +8,8 @@ import logging from typing import TYPE_CHECKING, Any from aiohttp import ClientResponseError -from tesla_fleet_api import EnergySpecific from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError +from tesla_fleet_api.tessie import EnergySite from tessie_api import get_state, get_status from homeassistant.core import HomeAssistant @@ -102,7 +102,7 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry: TessieConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific + self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySite ) -> None: """Initialize Tessie Energy Site Live coordinator.""" super().__init__( @@ -138,7 +138,7 @@ class TessieEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry: TessieConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific + self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySite ) -> None: """Initialize Tessie Energy Info coordinator.""" super().__init__( diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 4ddd63552f0..3f71bcb95e3 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.13"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.0.17"] } diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py index ca670b9650b..03652782cfe 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass -from tesla_fleet_api import EnergySpecific +from tesla_fleet_api.tessie import EnergySite from homeassistant.helpers.device_registry import DeviceInfo @@ -27,7 +27,7 @@ class TessieData: class TessieEnergyData: """Data for a Energy Site in the Tessie integration.""" - api: EnergySpecific + api: EnergySite live_coordinator: TessieEnergySiteLiveCoordinator info_coordinator: TessieEnergySiteInfoCoordinator id: int diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py index 1e857345278..77d8037fb14 100644 --- a/homeassistant/components/tessie/number.py +++ b/homeassistant/components/tessie/number.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import EnergySpecific +from tesla_fleet_api.tessie import EnergySite from tessie_api import set_charge_limit, set_charging_amps, set_speed_limit from homeassistant.components.number import ( @@ -90,7 +90,7 @@ VEHICLE_DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( class TessieNumberBatteryEntityDescription(NumberEntityDescription): """Describes Tessie Number entity.""" - func: Callable[[EnergySpecific, float], Awaitable[Any]] + func: Callable[[EnergySite, float], Awaitable[Any]] requires: str diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 4f0f5f67ebd..5de18f13140 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -76,8 +76,8 @@ "state": { "starting": "Starting", "charging": "[%key:common::state::charging%]", - "disconnected": "Disconnected", - "stopped": "Stopped", + "disconnected": "[%key:common::state::disconnected%]", + "stopped": "[%key:common::state::stopped%]", "complete": "Complete", "no_power": "No power" } @@ -246,81 +246,81 @@ "name": "Seat heater left", "state": { "off": "[%key:common::state::off%]", - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_fan_front_left": { "name": "Seat cooler left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_fan_front_right": { "name": "Seat cooler right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "components_customer_preferred_export_rule": { diff --git a/homeassistant/components/thermador/__init__.py b/homeassistant/components/thermador/__init__.py new file mode 100644 index 00000000000..2bd83b2ff71 --- /dev/null +++ b/homeassistant/components/thermador/__init__.py @@ -0,0 +1 @@ +"""Thermador virtual integration.""" diff --git a/homeassistant/components/thermador/manifest.json b/homeassistant/components/thermador/manifest.json new file mode 100644 index 00000000000..b09861623de --- /dev/null +++ b/homeassistant/components/thermador/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "thermador", + "name": "Thermador", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 2de9ebd1ec6..e565fdc7dd8 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -9,7 +9,11 @@ from typing import cast import tibber from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -159,7 +163,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): ) metadata = StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{home.name} {sensor_type}", source=TIBBER_DOMAIN, diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 937187c1c6f..c1c921343b8 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -219,18 +219,10 @@ class TodoItem: """A status or confirmation of the To-do item.""" due: datetime.date | datetime.datetime | None = None - """The date and time that a to-do is expected to be completed. - - This field may be a date or datetime depending whether the entity feature - DUE_DATE or DUE_DATETIME are set. - """ + """The date and time that a to-do is expected to be completed.""" description: str | None = None - """A more complete description of than that provided by the summary. - - This field may be set when TodoListEntityFeature.DESCRIPTION is supported by - the entity. - """ + """A more complete description than that provided by the summary.""" CACHED_PROPERTIES_WITH_ATTR_ = { diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index 03a8a169920..c3f52155d29 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -115,33 +115,33 @@ "name": "Tree pollen index", "state": { "none": "None", - "very_low": "Very low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very high" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "weed_pollen_index": { "name": "Weed pollen index", "state": { "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", - "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", - "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", - "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", - "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", - "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "grass_pollen_index": { "name": "Grass pollen index", "state": { "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", - "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", - "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", - "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", - "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", - "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "fire_index": { @@ -153,10 +153,10 @@ "uv_radiation_health_concern": { "name": "UV radiation health concern", "state": { - "low": "Low", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "high": "High", - "very_high": "Very high", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]", "extreme": "Extreme" } } diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json index 8bec4b112ac..3487f41efaa 100644 --- a/homeassistant/components/traccar_server/strings.json +++ b/homeassistant/components/traccar_server/strings.json @@ -47,7 +47,7 @@ "motion": { "name": "Motion", "state": { - "off": "Stopped", + "off": "[%key:common::state::stopped%]", "on": "Moving" } }, diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 73be7216a2f..bd1380ade4c 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -55,11 +55,9 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): @property def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" + """Return the source type of the device.""" if self._source_type == "PHONE": return SourceType.BLUETOOTH - if self._source_type == "KNOWN_WIFI": - return SourceType.ROUTER return SourceType.GPS @property diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 9f5b39a9657..f4adb1cc09e 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, cast from uuid import uuid4 from pytradfri import Gateway, RequestError @@ -54,7 +54,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - host = user_input.get(CONF_HOST, self._host) + host = cast(str, user_input.get(CONF_HOST, self._host)) try: auth = await authenticate( self.hass, host, user_input[KEY_SECURITY_CODE] diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 9ed7e167e71..66c46dd482e 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -6,7 +6,7 @@ "description": "You can find the security code on the back of your gateway.", "data": { "host": "[%key:common::config_flow::data::host%]", - "security_code": "Security Code" + "security_code": "Security code" }, "data_description": { "host": "Hostname or IP address of your Trådfri gateway." @@ -14,7 +14,7 @@ } }, "error": { - "invalid_security_code": "Failed to register with provided key. If this keeps happening, try restarting the gateway.", + "invalid_security_code": "Failed to register with provided code. If this keeps happening, try restarting the gateway.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout": "Timeout validating the code.", "cannot_authenticate": "Cannot authenticate, is Gateway paired with another server like e.g. Homekit?" diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index b6e2209fc57..8fdc6357156 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -18,7 +18,7 @@ "location": "[%key:common::config_flow::data::location%]" }, "data_description": { - "location": "Equal or part of name, description or camera id. Be as specific as possible to avoid getting multiple cameras as result" + "location": "Equal or part of name, description or camera ID. Be as specific as possible to avoid getting multiple cameras as result" } }, "multiple_cameras": { @@ -60,7 +60,7 @@ "name": "[%key:common::config_flow::data::location%]" }, "photo_url": { - "name": "Photo url" + "name": "Photo URL" }, "status": { "name": "Status" @@ -87,7 +87,7 @@ "name": "Photo time" }, "photo_url": { - "name": "Photo url" + "name": "Photo URL" }, "status": { "name": "Status" diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index 002dc421273..dfa64ed2953 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from pytrafikverket import TrafikverketFerry @@ -17,6 +18,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN from .util import create_unique_id +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): selector.TextSelector( @@ -81,7 +84,8 @@ class TVFerryConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NoFerryFound: errors["base"] = "invalid_route" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: return self.async_update_reload_and_abort( @@ -120,7 +124,8 @@ class TVFerryConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NoFerryFound: errors["base"] = "invalid_route" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: if not errors: diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index f6a58e464a1..fb39e14815e 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -86,8 +86,8 @@ async def validate_station( except UnknownError as error: _LOGGER.error("Unknown error occurred during validation %s", str(error)) errors["base"] = "cannot_connect" - except Exception as error: # noqa: BLE001 - _LOGGER.error("Unknown exception occurred during validation %s", str(error)) + except Exception: + _LOGGER.exception("Unknown exception occurred during validation") errors["base"] = "cannot_connect" return (stations, errors) @@ -266,7 +266,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): { CONF_API_KEY: api_key, CONF_FROM: train_from, - CONF_TO: user_input[CONF_TO], + CONF_TO: train_to, CONF_TIME: train_time, CONF_WEEKDAY: train_days, CONF_FILTER_PRODUCT: filter_product, diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index f4316b887b3..ee9fe264692 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from pytrafikverket.exceptions import ( @@ -25,6 +26,8 @@ from homeassistant.helpers.selector import ( from .const import CONF_STATION, DOMAIN +_LOGGER = logging.getLogger(__name__) + class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Weatherstation integration.""" @@ -56,7 +59,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_station" except MultipleWeatherStationsFound: errors["base"] = "more_stations" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected error") errors["base"] = "cannot_connect" else: return self.async_create_entry( @@ -102,7 +106,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_station" except MultipleWeatherStationsFound: errors["base"] = "more_stations" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: return self.async_update_reload_and_abort( @@ -132,7 +137,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_station" except MultipleWeatherStationsFound: errors["base"] = "more_stations" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: return self.async_update_reload_and_abort( diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index cb923037a24..bbc6764e3ef 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -89,7 +89,8 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( translation_key="wind_direction", value_fn=lambda data: data.winddirection, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), TrafikverketSensorEntityDescription( key="wind_speed", diff --git a/homeassistant/components/triggercmd/config_flow.py b/homeassistant/components/triggercmd/config_flow.py index fc02dd0b2fc..48c4eacfd5a 100644 --- a/homeassistant/components/triggercmd/config_flow.py +++ b/homeassistant/components/triggercmd/config_flow.py @@ -57,7 +57,7 @@ class TriggerCMDConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_TOKEN] = "invalid_token" except TRIGGERcmdConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index b1150be306a..9e40bda5d4d 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -454,6 +454,37 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, ), # Luminance Sensor # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 @@ -801,7 +832,6 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="total_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, subkey="power", ), TuyaSensorEntityDescription( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 83847d32fb5..c86e60c22ef 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -14,7 +14,7 @@ } }, "scan": { - "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login.\n\nContinue to the next step once you have completed this step in the app." + "description": "Use the Smart Life app or Tuya Smart app to scan the following QR code to complete the login.\n\nContinue to the next step once you have completed this step in the app." } }, "error": { diff --git a/homeassistant/components/twilio/strings.json b/homeassistant/components/twilio/strings.json index 871711ff087..f4b7dee707f 100644 --- a/homeassistant/components/twilio/strings.json +++ b/homeassistant/components/twilio/strings.json @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to setup [Webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "default": "To send events to Home Assistant, you will need to set up [webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } } } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index df4daa8782c..62ee4ede7d9 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.43.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 994f4f71c35..90433b0f728 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -14,8 +14,6 @@ import sys from typing import Any, overload from aiousbwatcher import AIOUSBWatcher, InotifyNotAvailableError -from serial.tools.list_ports import comports -from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol from homeassistant import config_entries @@ -43,7 +41,10 @@ from homeassistant.loader import USBMatcher, async_get_usb from .const import DOMAIN from .models import USBDevice -from .utils import usb_device_from_port +from .utils import ( + scan_serial_ports, + usb_device_from_port, # noqa: F401 +) _LOGGER = logging.getLogger(__name__) @@ -241,6 +242,13 @@ def _is_matching(device: USBDevice, matcher: USBMatcher | USBCallbackMatcher) -> return True +async def async_request_scan(hass: HomeAssistant) -> None: + """Request a USB scan.""" + usb_discovery: USBDiscovery = hass.data[DOMAIN] + if not usb_discovery.observer_active: + await usb_discovery.async_request_scan() + + class USBDiscovery: """Manage USB Discovery.""" @@ -417,14 +425,8 @@ class USBDiscovery: service_info, ) - async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: + async def _async_process_ports(self, usb_devices: Sequence[USBDevice]) -> None: """Process each discovered port.""" - _LOGGER.debug("Processing ports: %r", ports) - usb_devices = { - usb_device_from_port(port) - for port in ports - if port.vid is not None or port.pid is not None - } _LOGGER.debug("USB devices: %r", usb_devices) # CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and @@ -436,7 +438,7 @@ class USBDiscovery: if dev.device.startswith("/dev/cu.SLAB_USBtoUART") } - usb_devices = { + filtered_usb_devices = { dev for dev in usb_devices if dev.serial_number not in silabs_serials @@ -445,10 +447,12 @@ class USBDiscovery: and dev.device.startswith("/dev/cu.SLAB_USBtoUART") ) } + else: + filtered_usb_devices = set(usb_devices) - added_devices = usb_devices - self._last_processed_devices - removed_devices = self._last_processed_devices - usb_devices - self._last_processed_devices = usb_devices + added_devices = filtered_usb_devices - self._last_processed_devices + removed_devices = self._last_processed_devices - filtered_usb_devices + self._last_processed_devices = filtered_usb_devices _LOGGER.debug( "Added devices: %r, removed devices: %r", added_devices, removed_devices @@ -461,7 +465,7 @@ class USBDiscovery: except Exception: _LOGGER.exception("Error in USB port event callback") - for usb_device in usb_devices: + for usb_device in filtered_usb_devices: await self._async_process_discovered_usb_device(usb_device) @hass_callback @@ -483,7 +487,7 @@ class USBDiscovery: _LOGGER.debug("Executing comports scan") async with self._scan_lock: await self._async_process_ports( - await self.hass.async_add_executor_job(comports) + await self.hass.async_add_executor_job(scan_serial_ports) ) if self.initial_scan_done: return @@ -521,9 +525,7 @@ async def websocket_usb_scan( msg: dict[str, Any], ) -> None: """Scan for new usb devices.""" - usb_discovery: USBDiscovery = hass.data[DOMAIN] - if not usb_discovery.observer_active: - await usb_discovery.async_request_scan() + await async_request_scan(hass) connection.send_result(msg["id"]) diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py index d1d6fb17f3c..1bb620ec5f7 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -2,6 +2,9 @@ from __future__ import annotations +from collections.abc import Sequence + +from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo from .models import USBDevice @@ -17,3 +20,12 @@ def usb_device_from_port(port: ListPortInfo) -> USBDevice: manufacturer=port.manufacturer, description=port.description, ) + + +def scan_serial_ports() -> Sequence[USBDevice]: + """Scan serial ports for USB devices.""" + return [ + usb_device_from_port(port) + for port in comports() + if port.vid is not None or port.pid is not None + ] diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py index 30d1d153d9e..c7e6af8891a 100644 --- a/homeassistant/components/vallox/config_flow.py +++ b/homeassistant/components/vallox/config_flow.py @@ -108,7 +108,7 @@ class ValloxConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_HOST] = "invalid_host" except ValloxApiException: errors[CONF_HOST] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors[CONF_HOST] = "unknown" else: diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index f00206826d3..2a074cf2015 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -152,8 +152,8 @@ "selector": { "profile": { "options": { - "home": "Home", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", "boost": "Boost", "fireplace": "Fireplace", "extra": "Extra" diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index b86ec371b34..39dc297fe7d 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -5,10 +5,10 @@ "name": "[%key:component::valve::title%]", "state": { "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "closed": "[%key:common::state::closed%]", - "closing": "Closing", - "stopped": "Stopped" + "closing": "[%key:common::state::closing%]", + "stopped": "[%key:common::state::stopped%]" }, "state_attributes": { "current_position": { diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index a50395af115..35f94e54470 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -2,10 +2,11 @@ "config": { "step": { "user": { - "title": "Define the Velbus connection type", - "data": { - "name": "The name for this Velbus connection", - "port": "Connection string" + "title": "Define the Velbus connection", + "description": "How do you want to configure the Velbus hub?", + "menu_options": { + "network": "Via network connection", + "usbselect": "Via USB device" } }, "network": { diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index fdc75162651..1d916d0b8f6 100644 --- a/homeassistant/components/venstar/strings.json +++ b/homeassistant/components/venstar/strings.json @@ -32,7 +32,7 @@ "name": "Filter usage" }, "schedule_part": { - "name": "Schedule Part", + "name": "Schedule part", "state": { "morning": "Morning", "day": "Day", @@ -44,7 +44,7 @@ "active_stage": { "name": "Active stage", "state": { - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "first_stage": "First stage", "second_stage": "Second stage" } diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 04049f026bd..6ed0a2f018b 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -362,9 +362,9 @@ "ess_state": { "name": "Battery state", "state": { - "charge": "Charging", - "discharge": "Discharging", - "standby": "Standby" + "charge": "[%key:common::state::charging%]", + "discharge": "[%key:common::state::discharging%]", + "standby": "[%key:common::state::standby%]" } }, "ess_discharge_today": { @@ -412,7 +412,7 @@ "photovoltaic_status": { "name": "PV state", "state": { - "ready": "Standby", + "ready": "[%key:common::state::standby%]", "production": "Producing" } }, diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py index cdba7f1b8c2..5612591c595 100644 --- a/homeassistant/components/vilfo/config_flow.py +++ b/homeassistant/components/vilfo/config_flow.py @@ -114,8 +114,8 @@ class DomainConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception as err: # noqa: BLE001 - _LOGGER.error("Unexpected exception: %s", err) + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: await self.async_set_unique_id(info[CONF_ID]) diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index 5c98c3241e9..8dda4d49c7b 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -4,8 +4,16 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from json.decoder import JSONDecodeError from typing import Any, Final +from aiovodafone.exceptions import ( + AlreadyLogged, + CannotAuthenticate, + CannotConnect, + GenericLoginError, +) + from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, @@ -13,10 +21,11 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER +from .const import _LOGGER, DOMAIN from .coordinator import VodafoneConfigEntry, VodafoneStationRouter # Coordinator is used to centralize the data updates @@ -108,4 +117,25 @@ class VodafoneStationSensorEntity( async def async_press(self) -> None: """Triggers the Shelly button press service.""" - await self.entity_description.press_action(self.coordinator) + + try: + await self.entity_description.press_action(self.coordinator) + except CannotAuthenticate as err: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + translation_placeholders={"error": repr(err)}, + ) from err + except ( + CannotConnect, + AlreadyLogged, + GenericLoginError, + JSONDecodeError, + ) as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_execute_action", + translation_placeholders={"error": repr(err)}, + ) from err diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index fd0683bdacc..6641f5f5711 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -139,6 +139,47 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the device.""" + reconfigure_entry = self._get_reconfigure_entry() + if not user_input: + return self.async_show_form( + step_id="reconfigure", data_schema=user_form_schema(user_input) + ) + + updated_host = user_input[CONF_HOST] + + if reconfigure_entry.data[CONF_HOST] != updated_host: + self._async_abort_entries_match({CONF_HOST: updated_host}) + + errors: dict[str, str] = {} + + errors = {} + + try: + 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 Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates={CONF_HOST: updated_host} + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=user_form_schema(user_input), + errors=errors, + ) + class VodafoneStationOptionsFlowHandler(OptionsFlow): """Handle a option flow.""" diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index e3a595d5af8..a36af1466d6 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["aiovodafone==0.6.1"] } diff --git a/homeassistant/components/vodafone_station/quality_scale.yaml b/homeassistant/components/vodafone_station/quality_scale.yaml index f9fa27b3032..d60020f5e47 100644 --- a/homeassistant/components/vodafone_station/quality_scale.yaml +++ b/homeassistant/components/vodafone_station/quality_scale.yaml @@ -26,9 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: button presses not exception handled with HomeAssistantError + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done @@ -49,20 +47,14 @@ rules: status: exempt comment: device not discoverable docs-data-update: done - docs-examples: - status: todo - comment: add some automation example + docs-examples: done docs-known-limitations: status: exempt comment: no known limitations, yet docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: - status: todo - comment: add some info for troubleshooting - docs-use-cases: - status: todo - comment: add some use caes + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done @@ -72,9 +64,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: - status: todo - comment: handle host change + reconfiguration-flow: done repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index de4bc364d4b..958b774a485 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -7,7 +7,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "password": "Please enter the correct password for host: {host}" + "password": "[%key:component::vodafone_station::config::step::user::data_description::password%]" } }, "user": { @@ -21,22 +21,35 @@ "username": "The username for your Vodafone Station.", "password": "The password for your Vodafone Station." } + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "[%key:component::vodafone_station::config::step::user::data_description::host%]", + "username": "[%key:component::vodafone_station::config::step::user::data_description::username%]", + "password": "[%key:component::vodafone_station::config::step::user::data_description::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%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_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.", + "already_logged": "[%key:component::vodafone_station::config::abort::already_logged%]", "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.", + "model_not_supported": "[%key:component::vodafone_station::config::abort::model_not_supported%]", "unknown": "[%key:common::config_flow::error::unknown%]" } }, @@ -116,6 +129,9 @@ "update_failed": { "message": "Error fetching data: {error}" }, + "cannot_execute_action": { + "message": "Cannot execute requested action: {error}" + }, "cannot_authenticate": { "message": "Error authenticating: {error}" } diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 07e132a0b5b..9cc3a84c3cd 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -14,8 +14,8 @@ "eco": "Eco", "electric": "Electric", "gas": "Gas", - "high_demand": "High Demand", - "heat_pump": "Heat Pump", + "high_demand": "High demand", + "heat_pump": "Heat pump", "performance": "Performance" }, "state_attributes": { diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py index c1747af1f11..4f075a57228 100644 --- a/homeassistant/components/watergate/__init__.py +++ b/homeassistant/components/watergate/__init__.py @@ -15,11 +15,13 @@ from homeassistant.components.webhook import ( Response, async_generate_url, async_register, + async_unregister, ) from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN +from .const import AUTO_SHUT_OFF_EVENT_NAME, DOMAIN from .coordinator import WatergateConfigEntry, WatergateDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,8 +30,10 @@ WEBHOOK_TELEMETRY_TYPE = "telemetry" WEBHOOK_VALVE_TYPE = "valve" WEBHOOK_WIFI_CHANGED_TYPE = "wifi-changed" WEBHOOK_POWER_SUPPLY_CHANGED_TYPE = "power-supply-changed" +WEBHOOK_AUTO_SHUT_OFF = "auto-shut-off-report" PLATFORMS: list[Platform] = [ + Platform.EVENT, Platform.SENSOR, Platform.VALVE, ] @@ -72,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool: """Unload a config entry.""" webhook_id = entry.data[CONF_WEBHOOK_ID] - hass.components.webhook.async_unregister(webhook_id) + async_unregister(hass, webhook_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -120,6 +124,10 @@ def get_webhook_handler( coordinator_data.networking.rssi = data.rssi elif body_type == WEBHOOK_POWER_SUPPLY_CHANGED_TYPE: coordinator_data.state.power_supply = data.supply + elif body_type == WEBHOOK_AUTO_SHUT_OFF: + async_dispatcher_send( + hass, AUTO_SHUT_OFF_EVENT_NAME.format(data.type.lower()), data + ) coordinator.async_set_updated_data(coordinator_data) diff --git a/homeassistant/components/watergate/const.py b/homeassistant/components/watergate/const.py index 22a14330af9..c6726d9185f 100644 --- a/homeassistant/components/watergate/const.py +++ b/homeassistant/components/watergate/const.py @@ -3,3 +3,5 @@ DOMAIN = "watergate" MANUFACTURER = "Watergate" + +AUTO_SHUT_OFF_EVENT_NAME = "watergate_{}" diff --git a/homeassistant/components/watergate/event.py b/homeassistant/components/watergate/event.py new file mode 100644 index 00000000000..cf2447df4b3 --- /dev/null +++ b/homeassistant/components/watergate/event.py @@ -0,0 +1,78 @@ +"""Module contains the AutoShutOffEvent class for handling auto shut off events.""" + +from watergate_local_api.models.auto_shut_off_report import AutoShutOffReport + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WatergateConfigEntry +from .const import AUTO_SHUT_OFF_EVENT_NAME +from .coordinator import WatergateDataCoordinator +from .entity import WatergateEntity + +VOLUME_AUTO_SHUT_OFF = "volume_threshold" +DURATION_AUTO_SHUT_OFF = "duration_threshold" + + +DESCRIPTIONS: list[EventEntityDescription] = [ + EventEntityDescription( + translation_key="auto_shut_off_volume", + key="auto_shut_off_volume", + event_types=[VOLUME_AUTO_SHUT_OFF], + ), + EventEntityDescription( + translation_key="auto_shut_off_duration", + key="auto_shut_off_duration", + event_types=[DURATION_AUTO_SHUT_OFF], + ), +] + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WatergateConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Event entities from config entry.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + AutoShutOffEvent(coordinator, description) for description in DESCRIPTIONS + ) + + +class AutoShutOffEvent(WatergateEntity, EventEntity): + """Event for Auto Shut Off.""" + + def __init__( + self, + coordinator: WatergateDataCoordinator, + entity_description: EventEntityDescription, + ) -> None: + """Initialize Auto Shut Off Entity.""" + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + + async def async_added_to_hass(self): + """Register the callback for event handling when the entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + AUTO_SHUT_OFF_EVENT_NAME.format(self.event_types[0]), + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self, event: AutoShutOffReport) -> None: + self._trigger_event( + event.type.lower(), + {"volume": event.volume, "duration": event.duration}, + ) + self.async_write_ha_state() diff --git a/homeassistant/components/watergate/icons.json b/homeassistant/components/watergate/icons.json new file mode 100644 index 00000000000..28a0bfbc825 --- /dev/null +++ b/homeassistant/components/watergate/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "event": { + "auto_shut_off_volume": { + "default": "mdi:water" + }, + "auto_shut_off_duration": { + "default": "mdi:timelapse" + } + } + } +} diff --git a/homeassistant/components/watergate/quality_scale.yaml b/homeassistant/components/watergate/quality_scale.yaml index b116eff970e..73a39bd5264 100644 --- a/homeassistant/components/watergate/quality_scale.yaml +++ b/homeassistant/components/watergate/quality_scale.yaml @@ -17,10 +17,7 @@ rules: docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: | - Entities of this integration does not explicitly subscribe to events. + entity-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done diff --git a/homeassistant/components/watergate/strings.json b/homeassistant/components/watergate/strings.json index c312525e420..634e05e7973 100644 --- a/homeassistant/components/watergate/strings.json +++ b/homeassistant/components/watergate/strings.json @@ -19,6 +19,42 @@ } }, "entity": { + "event": { + "auto_shut_off_volume": { + "name": "Volume auto shut-off", + "state_attributes": { + "event_type": { + "state": { + "volume_threshold": "Volume", + "duration_threshold": "Duration" + } + }, + "volume": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]" + }, + "duration": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]" + } + } + }, + "auto_shut_off_duration": { + "name": "Duration auto shut-off", + "state_attributes": { + "event_type": { + "state": { + "volume_threshold": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]", + "duration_threshold": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]" + } + }, + "volume": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]" + }, + "duration": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]" + } + } + } + }, "sensor": { "water_meter_volume": { "name": "Water meter volume" diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index 8eee472fe5c..10c04b3283b 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -268,6 +268,7 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( key="wind_direction", translation_key="wind_direction", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, native_unit_of_measurement=DEGREE, event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], raw_data_conv_fn=lambda raw_data: raw_data.magnitude, diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index fb2927a58bb..a9afb5fe930 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -231,7 +231,7 @@ class WebDavBackupAgent(BackupAgent): return { metadata_content.backup_id: metadata_content for file_name in files - if file_name.endswith(".json") + if file_name.endswith(".metadata.json") if (metadata_content := await _download_metadata(file_name)) } diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py index f75544d25ad..e3e46d2575a 100644 --- a/homeassistant/components/webdav/config_flow.py +++ b/homeassistant/components/webdav/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from aiowebdav2.exceptions import UnauthorizedError +from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError import voluptuous as vol import yarl @@ -65,7 +65,9 @@ class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): result = await client.check() except UnauthorizedError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except MethodNotSupportedError: + errors["base"] = "invalid_method" + except Exception: _LOGGER.exception("Unexpected error") errors["base"] = "unknown" else: diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 30028cb28c9..63d093745d1 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.2"] + "requirements": ["aiowebdav2==0.4.5"] } diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index b03ffaf2a3d..ac6418f1239 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -21,6 +21,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_method": "The server does not support the required methods. Please check whether you have the correct URL. Check with your provider for the correct URL.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index a0d031834ae..fce85339430 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -21,7 +21,7 @@ type AsyncWebSocketCommandHandler = Callable[ DOMAIN: Final = "websocket_api" URL: Final = "/api/websocket" PENDING_MSG_PEAK: Final = 1024 -PENDING_MSG_PEAK_TIME: Final = 5 +PENDING_MSG_PEAK_TIME: Final = 10 # Maximum number of messages that can be pending at any given time. # This is effectively the upper limit of the number of entities # that can fire state changes within ~1 second. diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index ebca497193b..4250da149ad 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -14,7 +14,7 @@ from aiohttp import WSMsgType, web from aiohttp.http_websocket import WebSocketWriter from homeassistant.components.http import KEY_HASS, HomeAssistantView -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.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -73,6 +73,7 @@ class WebSocketHandler: "_authenticated", "_closing", "_connection", + "_debug", "_handle_task", "_hass", "_logger", @@ -107,6 +108,12 @@ class WebSocketHandler: self._message_queue: deque[bytes] = deque() self._ready_future: asyncio.Future[int] | None = None self._release_ready_queue_size: int = 0 + self._async_logging_changed() + + @callback + def _async_logging_changed(self, event: Event | None = None) -> None: + """Handle logging change.""" + self._debug = self._logger.isEnabledFor(logging.DEBUG) def __repr__(self) -> str: """Return the representation.""" @@ -137,7 +144,6 @@ class WebSocketHandler: logger = self._logger wsock = self._wsock loop = self._loop - is_debug_log_enabled = partial(logger.isEnabledFor, logging.DEBUG) debug = logger.debug can_coalesce = connection.can_coalesce ready_message_count = len(message_queue) @@ -157,14 +163,14 @@ class WebSocketHandler: if not can_coalesce or ready_message_count == 1: message = message_queue.popleft() - if is_debug_log_enabled(): + if self._debug: debug("%s: Sending %s", self.description, message) await send_bytes_text(message) continue coalesced_messages = b"".join((b"[", b",".join(message_queue), b"]")) message_queue.clear() - if is_debug_log_enabled(): + if self._debug: debug("%s: Sending %s", self.description, coalesced_messages) await send_bytes_text(coalesced_messages) except asyncio.CancelledError: @@ -325,6 +331,9 @@ class WebSocketHandler: unsub_stop = hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_handle_hass_stop ) + cancel_logging_listener = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) writer = wsock._writer # noqa: SLF001 if TYPE_CHECKING: @@ -354,6 +363,7 @@ class WebSocketHandler: "%s: Unexpected error inside websocket API", self.description ) finally: + cancel_logging_listener() unsub_stop() self._cancel_peak_checker() @@ -401,7 +411,7 @@ class WebSocketHandler: except ValueError as err: raise Disconnect("Received invalid JSON during auth phase") from err - if self._logger.isEnabledFor(logging.DEBUG): + if self._debug: self._logger.debug("%s: Received %s", self.description, auth_msg_data) connection = await auth.async_handle(auth_msg_data) # As the webserver is now started before the start @@ -463,7 +473,6 @@ class WebSocketHandler: wsock = self._wsock async_handle_str = connection.async_handle async_handle_binary = connection.async_handle_binary - _debug_enabled = partial(self._logger.isEnabledFor, logging.DEBUG) # Command phase while not wsock.closed: @@ -496,7 +505,7 @@ class WebSocketHandler: except ValueError as ex: raise Disconnect("Received invalid JSON.") from ex - if _debug_enabled(): + if self._debug: self._logger.debug( "%s: Received %s", self.description, command_msg_data ) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 0a8200c5700..6ae7de2c4b7 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -207,7 +207,7 @@ def _state_diff_event( additions[COMPRESSED_STATE_STATE] = new_state.state if old_state.last_changed != new_state.last_changed: additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed_timestamp - elif old_state.last_updated != new_state.last_updated: + elif old_state.last_updated_timestamp != new_state.last_updated_timestamp: additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated_timestamp if old_state_context.parent_id != new_state_context.parent_id: additions[COMPRESSED_STATE_CONTEXT] = {"parent_id": new_state_context.parent_id} diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py new file mode 100644 index 00000000000..e74ed596e1e --- /dev/null +++ b/homeassistant/components/whirlpool/entity.py @@ -0,0 +1,38 @@ +"""Base entity for the Whirlpool integration.""" + +from whirlpool.appliance import Appliance + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class WhirlpoolEntity(Entity): + """Base class for Whirlpool entities.""" + + _attr_has_entity_name = True + + def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None: + """Initialize the entity.""" + self._appliance = appliance + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, appliance.said)}, + name=appliance.name.capitalize(), + manufacturer="Whirlpool", + ) + self._attr_unique_id = f"{appliance.said}{unique_id_suffix}" + + async def async_added_to_hass(self) -> None: + """Register attribute updates callback.""" + self._appliance.register_attr_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Unregister attribute updates callback.""" + self._appliance.unregister_attr_callback(self.async_write_ha_state) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._appliance.get_online() diff --git a/homeassistant/components/whirlpool/icons.json b/homeassistant/components/whirlpool/icons.json new file mode 100644 index 00000000000..574b491090e --- /dev/null +++ b/homeassistant/components/whirlpool/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "washer_state": { + "default": "mdi:washing-machine" + }, + "dryer_state": { + "default": "mdi:tumble-dryer" + } + } + } +} diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index ace2e31791d..be47ab619e9 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.19.1"] + "requirements": ["whirlpool-sixth-sense==0.20.0"] } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index d0d13a128e2..44d17228135 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -1,12 +1,11 @@ """The Washer/Dryer Sensor for Whirlpool Appliances.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -import logging +from typing import override +from whirlpool.appliance import Appliance from whirlpool.washerdryer import MachineState, WasherDryer from homeassistant.components.sensor import ( @@ -15,25 +14,26 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import WhirlpoolConfigEntry -from .const import DOMAIN +from .entity import WhirlpoolEntity -TANK_FILL = { - "0": "unknown", - "1": "empty", - "2": "25", - "3": "50", - "4": "100", - "5": "active", +SCAN_INTERVAL = timedelta(minutes=5) + +WASHER_TANK_FILL = { + 0: "unknown", + 1: "empty", + 2: "25", + 3: "50", + 4: "100", + 5: "active", } -MACHINE_STATE = { +WASHER_DRYER_MACHINE_STATE = { MachineState.Standby: "standby", MachineState.Setting: "setting", MachineState.DelayCountdownMode: "delay_countdown", @@ -55,7 +55,7 @@ MACHINE_STATE = { MachineState.SystemInit: "system_initialize", } -CYCLE_FUNC = [ +WASHER_DRYER_CYCLE_FUNC = [ (WasherDryer.get_cycle_status_filling, "cycle_filling"), (WasherDryer.get_cycle_status_rinsing, "cycle_rinsing"), (WasherDryer.get_cycle_status_sensing, "cycle_sensing"), @@ -64,66 +64,72 @@ CYCLE_FUNC = [ (WasherDryer.get_cycle_status_washing, "cycle_washing"), ] -DOOR_OPEN = "door_open" -ICON_D = "mdi:tumble-dryer" -ICON_W = "mdi:washing-machine" - -_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=5) +STATE_DOOR_OPEN = "door_open" -def washer_state(washer: WasherDryer) -> str | None: - """Determine correct states for a washer.""" +def washer_dryer_state(washer_dryer: WasherDryer) -> str | None: + """Determine correct states for a washer/dryer.""" - if washer.get_attribute("Cavity_OpStatusDoorOpen") == "1": - return DOOR_OPEN + if washer_dryer.get_door_open(): + return STATE_DOOR_OPEN - machine_state = washer.get_machine_state() + machine_state = washer_dryer.get_machine_state() if machine_state == MachineState.RunningMainCycle: - for func, cycle_name in CYCLE_FUNC: - if func(washer): + for func, cycle_name in WASHER_DRYER_CYCLE_FUNC: + if func(washer_dryer): return cycle_name - return MACHINE_STATE.get(machine_state) + return WASHER_DRYER_MACHINE_STATE.get(machine_state) @dataclass(frozen=True, kw_only=True) class WhirlpoolSensorEntityDescription(SensorEntityDescription): - """Describes Whirlpool Washer sensor entity.""" + """Describes a Whirlpool sensor entity.""" - value_fn: Callable + value_fn: Callable[[Appliance], str | None] -SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( +WASHER_DRYER_STATE_OPTIONS = ( + list(WASHER_DRYER_MACHINE_STATE.values()) + + [value for _, value in WASHER_DRYER_CYCLE_FUNC] + + [STATE_DOOR_OPEN] +) + +WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( WhirlpoolSensorEntityDescription( key="state", - translation_key="whirlpool_machine", + translation_key="washer_state", device_class=SensorDeviceClass.ENUM, - options=( - list(MACHINE_STATE.values()) - + [value for _, value in CYCLE_FUNC] - + [DOOR_OPEN] - ), - value_fn=washer_state, + options=WASHER_DRYER_STATE_OPTIONS, + value_fn=washer_dryer_state, ), WhirlpoolSensorEntityDescription( key="DispenseLevel", translation_key="whirlpool_tank", entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, - options=list(TANK_FILL.values()), - value_fn=lambda WasherDryer: TANK_FILL.get( - WasherDryer.get_attribute("WashCavity_OpStatusBulkDispense1Level") - ), + options=list(WASHER_TANK_FILL.values()), + value_fn=lambda washer: WASHER_TANK_FILL.get(washer.get_dispense_1_level()), ), ) -SENSOR_TIMER: tuple[SensorEntityDescription] = ( +DRYER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( + WhirlpoolSensorEntityDescription( + key="state", + translation_key="dryer_state", + device_class=SensorDeviceClass.ENUM, + options=WASHER_DRYER_STATE_OPTIONS, + value_fn=washer_dryer_state, + ), +) + +WASHER_DRYER_TIME_SENSORS: tuple[SensorEntityDescription] = ( SensorEntityDescription( key="timeremaining", translation_key="end_time", device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:progress-clock", ), ) @@ -137,110 +143,71 @@ async def async_setup_entry( entities: list = [] appliances_manager = config_entry.runtime_data for washer_dryer in appliances_manager.washer_dryers: + sensor_descriptions = ( + DRYER_SENSORS + if "dryer" in washer_dryer.appliance_info.data_model.lower() + else WASHER_SENSORS + ) + entities.extend( - [WasherDryerClass(washer_dryer, description) for description in SENSORS] + WhirlpoolSensor(washer_dryer, description) + for description in sensor_descriptions ) entities.extend( - [ - WasherDryerTimeClass(washer_dryer, description) - for description in SENSOR_TIMER - ] + WasherDryerTimeSensor(washer_dryer, description) + for description in WASHER_DRYER_TIME_SENSORS ) async_add_entities(entities) -class WasherDryerClass(SensorEntity): - """A class for the whirlpool/maytag washer account.""" +class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): + """A class for the Whirlpool sensors.""" _attr_should_poll = False - _attr_has_entity_name = True def __init__( - self, washer_dryer: WasherDryer, description: WhirlpoolSensorEntityDescription + self, appliance: Appliance, description: WhirlpoolSensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washer_dryer - - if washer_dryer.name == "dryer": - self._attr_icon = ICON_D - else: - self._attr_icon = ICON_W - + super().__init__(appliance, unique_id_suffix=f"-{description.key}") self.entity_description: WhirlpoolSensorEntityDescription = description - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, washer_dryer.said)}, - name=washer_dryer.name.capitalize(), - manufacturer="Whirlpool", - ) - self._attr_unique_id = f"{washer_dryer.said}-{description.key}" - - async def async_added_to_hass(self) -> None: - """Register updates callback.""" - self._wd.register_attr_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Unregister updates callback.""" - self._wd.unregister_attr_callback(self.async_write_ha_state) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._wd.get_online() @property def native_value(self) -> StateType | str: """Return native value of sensor.""" - return self.entity_description.value_fn(self._wd) + return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeClass(RestoreSensor): - """A timestamp class for the whirlpool/maytag washer account.""" +class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): + """A timestamp class for the Whirlpool washer/dryer.""" _attr_should_poll = True - _attr_has_entity_name = True def __init__( self, washer_dryer: WasherDryer, description: SensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washer_dryer + super().__init__(washer_dryer, unique_id_suffix=f"-{description.key}") + self.entity_description = description - if washer_dryer.name == "dryer": - self._attr_icon = ICON_D - else: - self._attr_icon = ICON_W - - self.entity_description: SensorEntityDescription = description + self._wd = washer_dryer self._running: bool | None = None - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, washer_dryer.said)}, - name=washer_dryer.name.capitalize(), - manufacturer="Whirlpool", - ) - self._attr_unique_id = f"{washer_dryer.said}-{description.key}" + self._value: datetime | None = None async def async_added_to_hass(self) -> None: - """Connect washer/dryer to the cloud.""" + """Register attribute updates callback.""" if restored_data := await self.async_get_last_sensor_data(): - self._attr_native_value = restored_data.native_value + if isinstance(restored_data.native_value, datetime): + self._value = restored_data.native_value await super().async_added_to_hass() - self._wd.register_attr_callback(self.update_from_latest_data) - - async def async_will_remove_from_hass(self) -> None: - """Close Whrilpool Appliance sockets before removing.""" - self._wd.unregister_attr_callback(self.update_from_latest_data) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._wd.get_online() async def async_update(self) -> None: """Update status of Whirlpool.""" await self._wd.fetch_data() - @callback - def update_from_latest_data(self) -> None: + @override + @property + def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" machine_state = self._wd.get_machine_state() now = utcnow() @@ -250,19 +217,16 @@ class WasherDryerTimeClass(RestoreSensor): and self._running ): self._running = False - self._attr_native_value = now - self._async_write_ha_state() + self._value = now if machine_state is MachineState.RunningMainCycle: self._running = True - new_timestamp = now + timedelta( - seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) - ) + new_timestamp = now + timedelta(seconds=self._wd.get_time_remaining()) - if self._attr_native_value is None or ( - isinstance(self._attr_native_value, datetime) - and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60) + if self._value is None or ( + isinstance(self._value, datetime) + and abs(new_timestamp - self._value) > timedelta(seconds=60) ): - self._attr_native_value = new_timestamp - self._async_write_ha_state() + self._value = new_timestamp + return self._value diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 95df3fb9098..56fee795237 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -43,35 +43,64 @@ }, "entity": { "sensor": { - "whirlpool_machine": { - "name": "State", + "washer_state": { "state": { "standby": "[%key:common::state::standby%]", "setting": "Setting", - "delay_countdown": "Delay Countdown", - "delay_paused": "Delay Paused", - "smart_delay": "Smart Delay", - "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::whirlpool_machine::state::smart_delay%]", + "delay_countdown": "Delay countdown", + "delay_paused": "Delay paused", + "smart_delay": "Smart delay", + "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", "pause": "[%key:common::state::paused%]", - "running_maincycle": "Running Maincycle", - "running_postcycle": "Running Postcycle", + "running_maincycle": "Running maincycle", + "running_postcycle": "Running postcycle", "exception": "Exception", "complete": "Complete", - "power_failure": "Power Failure", - "service_diagnostic_mode": "Service Diagnostic Mode", - "factory_diagnostic_mode": "Factory Diagnostic Mode", - "life_test": "Life Test", - "customer_focus_mode": "Customer Focus Mode", - "demo_mode": "Demo Mode", - "hard_stop_or_error": "Hard Stop or Error", - "system_initialize": "System Initialize", - "cycle_filling": "Cycle Filling", - "cycle_rinsing": "Cycle Rinsing", - "cycle_sensing": "Cycle Sensing", - "cycle_soaking": "Cycle Soaking", - "cycle_spinning": "Cycle Spinning", - "cycle_washing": "Cycle Washing", - "door_open": "Door Open" + "power_failure": "Power failure", + "service_diagnostic_mode": "Service diagnostic mode", + "factory_diagnostic_mode": "Factory diagnostic mode", + "life_test": "Life test", + "customer_focus_mode": "Customer focus mode", + "demo_mode": "Demo mode", + "hard_stop_or_error": "Hard stop or error", + "system_initialize": "System initialize", + "cycle_filling": "Cycle filling", + "cycle_rinsing": "Cycle rinsing", + "cycle_sensing": "Cycle sensing", + "cycle_soaking": "Cycle soaking", + "cycle_spinning": "Cycle spinning", + "cycle_washing": "Cycle washing", + "door_open": "Door open" + } + }, + "dryer_state": { + "state": { + "standby": "[%key:common::state::standby%]", + "setting": "[%key:component::whirlpool::entity::sensor::washer_state::state::setting%]", + "delay_countdown": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_countdown%]", + "delay_paused": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_paused%]", + "smart_delay": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", + "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", + "pause": "[%key:common::state::paused%]", + "running_maincycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_maincycle%]", + "running_postcycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_postcycle%]", + "exception": "[%key:component::whirlpool::entity::sensor::washer_state::state::exception%]", + "complete": "[%key:component::whirlpool::entity::sensor::washer_state::state::complete%]", + "power_failure": "[%key:component::whirlpool::entity::sensor::washer_state::state::power_failure%]", + "service_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::service_diagnostic_mode%]", + "factory_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::factory_diagnostic_mode%]", + "life_test": "[%key:component::whirlpool::entity::sensor::washer_state::state::life_test%]", + "customer_focus_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::customer_focus_mode%]", + "demo_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::demo_mode%]", + "hard_stop_or_error": "[%key:component::whirlpool::entity::sensor::washer_state::state::hard_stop_or_error%]", + "system_initialize": "[%key:component::whirlpool::entity::sensor::washer_state::state::system_initialize%]", + "cycle_filling": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_filling%]", + "cycle_rinsing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_rinsing%]", + "cycle_sensing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_sensing%]", + "cycle_soaking": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_soaking%]", + "cycle_spinning": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_spinning%]", + "cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]", + "door_open": "[%key:component::whirlpool::entity::sensor::washer_state::state::door_open%]" } }, "whirlpool_tank": { diff --git a/homeassistant/components/whois/config_flow.py b/homeassistant/components/whois/config_flow.py index cb4326d996d..a8306be7632 100644 --- a/homeassistant/components/whois/config_flow.py +++ b/homeassistant/components/whois/config_flow.py @@ -11,6 +11,8 @@ from whois.exceptions import ( UnknownDateFormat, UnknownTld, WhoisCommandFailed, + WhoisPrivateRegistry, + WhoisQuotaExceeded, ) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -48,6 +50,10 @@ class WhoisFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "unexpected_response" except UnknownDateFormat: errors["base"] = "unknown_date_format" + except WhoisPrivateRegistry: + errors["base"] = "private_registry" + except WhoisQuotaExceeded: + errors["base"] = "quota_exceeded" else: return self.async_create_entry( title=self.imported_name or user_input[CONF_DOMAIN], diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json index c28c079784d..3b0f9dfd4d1 100644 --- a/homeassistant/components/whois/strings.json +++ b/homeassistant/components/whois/strings.json @@ -11,7 +11,9 @@ "unexpected_response": "Unexpected response from whois server", "unknown_date_format": "Unknown date format in whois server response", "unknown_tld": "The given TLD is unknown or not available to this integration", - "whois_command_failed": "Whois command failed: could not retrieve whois information" + "whois_command_failed": "Whois command failed: could not retrieve whois information", + "private_registry": "The given domain is registered in a private registry and cannot be monitored", + "quota_exceeded": "Your whois quota has been exceeded for this TLD" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 775ef5cdaab..746fa244c8e 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -313,9 +313,9 @@ "battery": { "name": "[%key:component::sensor::entity_component::battery::name%]", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index 7b1ecdcdb6b..947e7f0b638 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -26,5 +26,5 @@ ], "documentation": "https://www.home-assistant.io/integrations/wiz", "iot_class": "local_push", - "requirements": ["pywizlight==0.5.14"] + "requirements": ["pywizlight==0.6.2"] } diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index e340c323151..76837652ae5 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -79,9 +79,10 @@ class WLEDPresetSelect(WLEDEntity, SelectEntity): super().__init__(coordinator=coordinator) self._attr_unique_id = f"{coordinator.data.info.mac_address}_preset" - self._attr_options = [ - preset.name for preset in self.coordinator.data.presets.values() - ] + sorted_values = sorted( + coordinator.data.presets.values(), key=lambda preset: preset.name + ) + self._attr_options = [preset.name for preset in sorted_values] @property def available(self) -> bool: @@ -115,9 +116,10 @@ class WLEDPlaylistSelect(WLEDEntity, SelectEntity): super().__init__(coordinator=coordinator) self._attr_unique_id = f"{coordinator.data.info.mac_address}_playlist" - self._attr_options = [ - playlist.name for playlist in self.coordinator.data.playlists.values() - ] + sorted_values = sorted( + coordinator.data.playlists.values(), key=lambda playlist: playlist.name + ) + self._attr_options = [playlist.name for playlist in sorted_values] @property def available(self) -> bool: @@ -159,9 +161,10 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity): self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = f"{coordinator.data.info.mac_address}_palette_{segment}" - self._attr_options = [ - palette.name for palette in self.coordinator.data.palettes.values() - ] + sorted_values = sorted( + coordinator.data.palettes.values(), key=lambda palette: palette.name + ) + self._attr_options = [palette.name for palette in sorted_values] self._segment = segment @property diff --git a/homeassistant/components/wolflink/strings.json b/homeassistant/components/wolflink/strings.json index b1c332984a1..1f1eb5e310d 100644 --- a/homeassistant/components/wolflink/strings.json +++ b/homeassistant/components/wolflink/strings.json @@ -28,9 +28,9 @@ "sensor": { "state": { "state": { - "ein": "[%key:common::state::enabled%]", - "deaktiviert": "Inactive", - "aus": "[%key:common::state::disabled%]", + "ein": "[%key:common::state::on%]", + "deaktiviert": "[%key:common::state::disabled%]", + "aus": "[%key:common::state::off%]", "standby": "[%key:common::state::standby%]", "auto": "Auto", "permanent": "Permanent", diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 895c7cd50e2..b0b1e9fcc02 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, NumberSelectorMode, + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -79,10 +80,19 @@ def add_province_and_language_to_schema( } if provinces := all_countries.get(country): + if _country.subdivisions_aliases and ( + subdiv_aliases := _country.get_subdivision_aliases() + ): + province_options: list[Any] = [ + SelectOptionDict(value=k, label=", ".join(v)) + for k, v in subdiv_aliases.items() + ] + else: + province_options = provinces province_schema = { vol.Optional(CONF_PROVINCE): SelectSelector( SelectSelectorConfig( - options=provinces, + options=province_options, mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_PROVINCE, ) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index cc6b0f30002..b08a5ed9fff 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.68"] + "requirements": ["holidays==0.69"] } diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 87fa294dbba..feedc52331b 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -2,13 +2,13 @@ "title": "Workday", "config": { "abort": { - "already_configured": "Workday has already been setup with chosen configuration" + "already_configured": "Workday has already been set up with chosen configuration" }, "step": { "user": { "data": { "name": "[%key:common::config_flow::data::name%]", - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" } }, "options": { @@ -18,7 +18,7 @@ "days_offset": "Offset", "workdays": "Days to include", "add_holidays": "Add holidays", - "remove_holidays": "Remove Holidays", + "remove_holidays": "Remove holidays", "province": "Subdivision of country", "language": "Language for named holidays", "category": "Additional category as holiday" @@ -116,14 +116,14 @@ }, "issues": { "bad_country": { - "title": "Configured Country for {title} does not exist", + "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%]" + "country": "[%key:common::config_flow::data::country%]" } }, "province": { @@ -133,7 +133,7 @@ "province": "[%key:component::workday::config::step::options::data::province%]" }, "data_description": { - "province": "State, Territory, Province, Region of Country" + "province": "[%key:component::workday::config::step::options::data_description::province%]" } } } @@ -150,7 +150,7 @@ "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%]" + "province": "[%key:component::workday::config::step::options::data_description::province%]" } } } @@ -217,7 +217,7 @@ "services": { "check_date": { "name": "Check date", - "description": "Check if date is workday.", + "description": "Checks if a given date is a workday.", "fields": { "check_date": { "name": "Date", diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index d639933ece6..4e76287d8e7 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -8,15 +8,19 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .devices import SatelliteDevice from .models import DomainDataItem +from .websocket_api import async_register_websocket_api _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + SATELLITE_PLATFORMS = [ Platform.ASSIST_SATELLITE, Platform.BINARY_SENSOR, @@ -28,11 +32,19 @@ SATELLITE_PLATFORMS = [ __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup", "async_setup_entry", "async_unload_entry", ] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Wyoming integration.""" + async_register_websocket_api(hass) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load Wyoming.""" service = await WyomingService.create(entry.data["host"], entry.data["port"]) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 4a1a4c3a246..2578b0e5278 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -40,10 +40,10 @@ "noise_suppression_level": { "name": "Noise suppression level", "state": { - "off": "Off", - "low": "Low", - "medium": "Medium", - "high": "High", + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "max": "Max" } }, diff --git a/homeassistant/components/wyoming/websocket_api.py b/homeassistant/components/wyoming/websocket_api.py new file mode 100644 index 00000000000..613238c302a --- /dev/null +++ b/homeassistant/components/wyoming/websocket_api.py @@ -0,0 +1,42 @@ +"""Wyoming Websocket API.""" + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .models import DomainDataItem + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the websocket API.""" + websocket_api.async_register_command(hass, websocket_info) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "wyoming/info"}) +def websocket_info( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List service information for Wyoming all config entries.""" + entry_items: dict[str, DomainDataItem] = hass.data.get(DOMAIN, {}) + + connection.send_result( + msg["id"], + { + "info": { + entry_id: item.service.info.to_dict() + for entry_id, item in entry_items.items() + } + }, + ) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 26dd82c73bc..d7156246d38 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.33.0"] + "requirements": ["xiaomi-ble==0.35.0"] } diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index 01f15ff09b8..57dfaead232 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -9,9 +9,11 @@ from xiaomi_ble.parser import ExtendedSensorDeviceClass from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataUpdate, + PassiveBluetoothEntityKey, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( + EntityDescription, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -78,6 +80,7 @@ SENSOR_DESCRIPTIONS = { icon="mdi:omega", native_unit_of_measurement=Units.OHM, state_class=SensorStateClass.MEASUREMENT, + translation_key="impedance", ), # Mass sensor (kg) (DeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription( @@ -93,6 +96,7 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfMass.KILOGRAMS, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + translation_key="weight_non_stabilized", ), (DeviceClass.MOISTURE, Units.PERCENTAGE): SensorEntityDescription( key=f"{DeviceClass.MOISTURE}_{Units.PERCENTAGE}", @@ -180,18 +184,20 @@ def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, ) -> PassiveBluetoothDataUpdate[float | None]: """Convert a sensor update to a bluetooth data update.""" + entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] = { + device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + (description.device_class, description.native_unit_of_measurement) + ] + for device_key, description in sensor_update.entity_descriptions.items() + if description.device_class + } + return PassiveBluetoothDataUpdate( devices={ device_id: sensor_device_info_to_hass_device_info(device_info) for device_id, device_info in sensor_update.devices.items() }, - entity_descriptions={ - device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ - (description.device_class, description.native_unit_of_measurement) - ] - for device_key, description in sensor_update.entity_descriptions.items() - if description.device_class - }, + entity_descriptions=entity_descriptions, entity_data={ device_key_to_bluetooth_entity_key(device_key): cast( float | None, sensor_values.native_value @@ -201,6 +207,17 @@ def sensor_update_to_bluetooth_data_update( entity_names={ device_key_to_bluetooth_entity_key(device_key): sensor_values.name for device_key, sensor_values in sensor_update.entity_values.items() + # Add names where the entity description has neither a translation_key nor + # a device_class + if ( + description := entity_descriptions.get( + device_key_to_bluetooth_entity_key(device_key) + ) + ) + is None + or ( + description.translation_key is None and description.device_class is None + ) }, ) diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 4ea4a47c61e..cdee3fc3838 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -227,6 +227,14 @@ } } } + }, + "sensor": { + "impedance": { + "name": "Impedance" + }, + "weight_non_stabilized": { + "name": "Weight non stabilized" + } } } } diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index bd3b3499689..7df4dc18283 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -88,9 +88,9 @@ }, "ptc_level": { "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index ebcf0b3af63..fd8d403da8d 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -71,8 +71,8 @@ "volume": { "name": "Volume", "state": { - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "off": "[%key:common::state::off%]" } } diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index cf7bc9c9035..07970cb25ca 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.43.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.44.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 52ae8281f59..8c297c68670 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.4.8"] + "requirements": ["yolink-api==0.4.9"] } diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 8ec7612fd73..b4cfe80f287 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -72,7 +72,11 @@ }, "power_failure_alarm_volume": { "name": "Power failure alarm volume", - "state": { "low": "Low", "medium": "Medium", "high": "High" } + "state": { + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } }, "power_failure_alarm_beep": { "name": "Power failure alarm beep", diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 48336422585..76d74965b34 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -7,7 +7,6 @@ import logging from typing import Any import voluptuous as vol -from youtubeaio.helper import first from youtubeaio.types import AuthScope, ForbiddenError from youtubeaio.youtube import YouTube @@ -96,8 +95,12 @@ class OAuth2FlowHandler( """Create an entry for the flow, or update existing entry.""" try: youtube = await self.get_resource(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - own_channel = await first(youtube.get_user_channels()) - if own_channel is None or own_channel.snippet is None: + own_channels = [ + channel + async for channel in youtube.get_user_channels() + if channel.snippet is not None + ] + if not own_channels: return self.async_abort( reason="no_channel", description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, @@ -111,10 +114,10 @@ class OAuth2FlowHandler( except Exception as ex: # noqa: BLE001 LOGGER.error("Unknown error occurred: %s", ex.args) return self.async_abort(reason="unknown") - self._title = own_channel.snippet.title + self._title = own_channels[0].snippet.title self._data = data - await self.async_set_unique_id(own_channel.channel_id) + await self.async_set_unique_id(own_channels[0].channel_id) if self.source != SOURCE_REAUTH: self._abort_if_unique_id_configured() @@ -138,13 +141,39 @@ class OAuth2FlowHandler( options=user_input, ) youtube = await self.get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + + # Get user's own channels + own_channels = [ + channel + async for channel in youtube.get_user_channels() + if channel.snippet is not None + ] + if not own_channels: + return self.async_abort( + reason="no_channel", + description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, + ) + + # Start with user's own channels selectable_channels = [ SelectOptionDict( - value=subscription.snippet.channel_id, - label=subscription.snippet.title, + value=channel.channel_id, + label=f"{channel.snippet.title} (Your Channel)", ) - async for subscription in youtube.get_user_subscriptions() + for channel in own_channels ] + + # Add subscribed channels + selectable_channels.extend( + [ + SelectOptionDict( + value=subscription.snippet.channel_id, + label=subscription.snippet.title, + ) + async for subscription in youtube.get_user_subscriptions() + ] + ) + if not selectable_channels: return self.async_abort(reason="no_subscriptions") return self.async_show_form( @@ -175,13 +204,39 @@ class YouTubeOptionsFlowHandler(OptionsFlow): await youtube.set_user_authentication( self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN], [AuthScope.READ_ONLY] ) + + # Get user's own channels + own_channels = [ + channel + async for channel in youtube.get_user_channels() + if channel.snippet is not None + ] + if not own_channels: + return self.async_abort( + reason="no_channel", + description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, + ) + + # Start with user's own channels selectable_channels = [ SelectOptionDict( - value=subscription.snippet.channel_id, - label=subscription.snippet.title, + value=channel.channel_id, + label=f"{channel.snippet.title} (Your Channel)", ) - async for subscription in youtube.get_user_subscriptions() + for channel in own_channels ] + + # Add subscribed channels + selectable_channels.extend( + [ + SelectOptionDict( + value=subscription.snippet.channel_id, + label=subscription.snippet.title, + ) + async for subscription in youtube.get_user_subscriptions() + ] + ) + return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 5846092e555..fdb9d51185c 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -82,7 +82,8 @@ SENSOR_TYPES: tuple[ZamgSensorEntityDescription, ...] = ( key="wind_bearing", name="Wind Bearing", native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, para_name="DD", ), ZamgSensorEntityDescription( diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py index 2ab46820b56..ccb6733c650 100644 --- a/homeassistant/components/zengge/light.py +++ b/homeassistant/components/zengge/light.py @@ -2,138 +2,38 @@ from __future__ import annotations -import logging -from typing import Any - import voluptuous as vol -from zengge import zengge -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_HS_COLOR, - ATTR_WHITE, - PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, - ColorMode, - LightEntity, -) +from homeassistant.components.light import PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import color as color_util - -_LOGGER = logging.getLogger(__name__) DEVICE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string}) +DOMAIN = "zengge" PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend( {vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}} ) -def setup_platform( +def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Zengge platform.""" - lights = [] - for address, device_config in config[CONF_DEVICES].items(): - light = ZenggeLight(device_config[CONF_NAME], address) - if light.is_valid: - lights.append(light) - - add_entities(lights, True) - - -class ZenggeLight(LightEntity): - """Representation of a Zengge light.""" - - _attr_supported_color_modes = {ColorMode.HS, ColorMode.WHITE} - - def __init__(self, name: str, address: str) -> None: - """Initialize the light.""" - - self._attr_name = name - self._attr_unique_id = address - self.is_valid = True - self._bulb = zengge(address) - self._white = 0 - self._attr_brightness = 0 - self._attr_hs_color = (0, 0) - self._attr_is_on = False - if self._bulb.connect() is False: - self.is_valid = False - _LOGGER.error("Failed to connect to bulb %s, %s", address, name) - return - - @property - def white_value(self) -> int: - """Return the white property.""" - return self._white - - @property - def color_mode(self) -> ColorMode: - """Return the current color mode.""" - if self._white != 0: - return ColorMode.WHITE - return ColorMode.HS - - def _set_rgb(self, red: int, green: int, blue: int) -> None: - """Set the rgb state.""" - self._bulb.set_rgb(red, green, blue) - - def _set_white(self, white): - """Set the white state.""" - return self._bulb.set_white(white) - - def turn_on(self, **kwargs: Any) -> None: - """Turn the specified light on.""" - self._attr_is_on = True - self._bulb.on() - - hs_color = kwargs.get(ATTR_HS_COLOR) - white = kwargs.get(ATTR_WHITE) - brightness = kwargs.get(ATTR_BRIGHTNESS) - - if white is not None: - # Change the bulb to white - self._attr_brightness = white - self._white = white - self._attr_hs_color = (0, 0) - - if hs_color is not None: - # Change the bulb to hs - self._white = 0 - self._attr_hs_color = hs_color - - if brightness is not None: - self._attr_brightness = brightness - - if self._white != 0: - self._set_white(self.brightness) - else: - assert self.hs_color is not None - assert self.brightness is not None - rgb = color_util.color_hsv_to_RGB( - self.hs_color[0], self.hs_color[1], self.brightness / 255 * 100 - ) - self._set_rgb(*rgb) - - def turn_off(self, **kwargs: Any) -> None: - """Turn the specified light off.""" - self._attr_is_on = False - self._bulb.off() - - def update(self) -> None: - """Synchronise internal state with the actual light state.""" - rgb = self._bulb.get_colour() - hsv = color_util.color_RGB_to_hsv(*rgb) - self._attr_hs_color = hsv[:2] - self._attr_brightness = int((hsv[2] / 100) * 255) - self._white = self._bulb.get_white() - if self._white: - self._attr_brightness = self._white - self._attr_is_on = self._bulb.get_on() + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "led_ble_url": "https://www.home-assistant.io/integrations/led_ble/", + }, + ) diff --git a/homeassistant/components/zengge/manifest.json b/homeassistant/components/zengge/manifest.json index 03d989c5f3b..daa63b4de3d 100644 --- a/homeassistant/components/zengge/manifest.json +++ b/homeassistant/components/zengge/manifest.json @@ -5,6 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/zengge", "iot_class": "local_polling", "loggers": ["zengge"], - "quality_scale": "legacy", - "requirements": ["bluepy==1.3.0", "zengge==0.2"] + "quality_scale": "legacy" } diff --git a/homeassistant/components/zengge/strings.json b/homeassistant/components/zengge/strings.json new file mode 100644 index 00000000000..abc3b2450aa --- /dev/null +++ b/homeassistant/components/zengge/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "integration_removed": { + "title": "The Zengge integration has been removed", + "description": "The Zengge integration has been removed from Home Assistant. Support for Zengge lights is provided by the `led_ble` integration.\n\nTo resolve this issue, please remove the (now defunct) `zengge` light configuration from your Home Assistant configuration and [configure the `led_ble` integration]({led_ble_url})." + } + } +} diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index e80b6b8cfdb..86f8dbca792 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -145,8 +145,6 @@ def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf: if DOMAIN in hass.data: return cast(HaAsyncZeroconf, hass.data[DOMAIN]) - logging.getLogger("zeroconf").setLevel(logging.NOTSET) - zeroconf = HaZeroconf(**_async_get_zc_args(hass)) aio_zc = HaAsyncZeroconf(zc=zeroconf) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d16ce5a64bf..1c2d6556271 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.52"], + "requirements": ["zha==0.0.55"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 23bb9ae051e..79cb05c3a0e 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -610,6 +610,12 @@ }, "flow_switch": { "name": "Flow switch" + }, + "water_leak": { + "name": "Water leak" + }, + "water_supply": { + "name": "Water supply" } }, "button": { @@ -1101,6 +1107,27 @@ }, "shutdown_timer": { "name": "Shutdown timer" + }, + "calibration_vertical_run_time_up": { + "name": "Calibration vertical run time up" + }, + "calibration_vertical_run_time_down": { + "name": "Calibration vertical run time down" + }, + "calibration_rotation_run_time_up": { + "name": "Calibration rotation run time up" + }, + "calibration_rotation_run_time_down": { + "name": "Calibration rotation run time down" + }, + "impulse_mode_duration": { + "name": "Impulse mode duration" + }, + "water_duration": { + "name": "Water duration" + }, + "water_interval": { + "name": "Water interval" } }, "select": { @@ -1319,6 +1346,9 @@ }, "hysteresis_mode": { "name": "Hysteresis mode" + }, + "speed": { + "name": "Speed" } }, "sensor": { @@ -1457,7 +1487,7 @@ "adaptation_run_status": { "name": "Adaptation run status", "state": { - "nothing": "Idle", + "nothing": "[%key:common::state::idle%]", "something": "State" }, "state_attributes": { @@ -1666,6 +1696,9 @@ }, "last_watering_duration": { "name": "Last watering duration" + }, + "device_status": { + "name": "Device status" } }, "switch": { diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index d07846c8dcc..1439aa0ca0f 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -67,7 +67,45 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription): # Mappings for Notification sensors -# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json +# https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx +# +# Mapping rules: +# The catch all description should not have a device class and be marked as diagnostic. +# +# The following notifications have been moved to diagnostic: +# Smoke Alarm +# - Alarm silenced +# - Replacement required +# - Replacement required, End-of-life +# - Maintenance required, planned periodic inspection +# - Maintenance required, dust in device +# CO Alarm +# - Carbon monoxide test +# - Replacement required +# - Replacement required, End-of-life +# - Alarm silenced +# - Maintenance required, planned periodic inspection +# CO2 Alarm +# - Carbon dioxide test +# - Replacement required +# - Replacement required, End-of-life +# - Alarm silenced +# - Maintenance required, planned periodic inspection +# Heat Alarm +# - Rapid temperature rise (location provided) +# - Rapid temperature rise +# - Rapid temperature fall (location provided) +# - Rapid temperature fall +# - Heat alarm test +# - Alarm silenced +# - Replacement required, End-of-life +# - Maintenance required, dust in device +# - Maintenance required, planned periodic inspection + +# Water Alarm +# - Replace water filter +# - Sump pump failure + NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = ( NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected @@ -75,10 +113,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.SMOKE, ), + NotificationZWaveJSEntityDescription( + # NotificationType 1: Smoke Alarm - State Id's 4, 5, 7, 8 + key=NOTIFICATION_SMOKE_ALARM, + states=("4", "5", "7", "8"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - All other State Id's key=NOTIFICATION_SMOKE_ALARM, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - State Id's 1 and 2 @@ -86,10 +131,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.CO, ), + NotificationZWaveJSEntityDescription( + # NotificationType 2: Carbon Monoxide - State Id 4, 5, 7 + key=NOTIFICATION_CARBON_MONOOXIDE, + states=("4", "5", "7"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - All other State Id's key=NOTIFICATION_CARBON_MONOOXIDE, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - State Id's 1 and 2 @@ -97,10 +149,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.GAS, ), + NotificationZWaveJSEntityDescription( + # NotificationType 3: Carbon Dioxide - State Id's 4, 5, 7 + key=NOTIFICATION_CARBON_DIOXIDE, + states=("4", "5", "7"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - All other State Id's key=NOTIFICATION_CARBON_DIOXIDE, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 4: Heat - State Id's 1, 2, 5, 6 (heat/underheat) @@ -109,20 +168,34 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = device_class=BinarySensorDeviceClass.HEAT, ), NotificationZWaveJSEntityDescription( - # NotificationType 4: Heat - All other State Id's + # NotificationType 4: Heat - State ID's 8, A, B key=NOTIFICATION_HEAT, + states=("8", "10", "11"), device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( - # NotificationType 5: Water - State Id's 1, 2, 3, 4 + # NotificationType 4: Heat - All other State Id's + key=NOTIFICATION_HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 5: Water - State Id's 1, 2, 3, 4, 6, 7, 8, 9, 0A key=NOTIFICATION_WATER, - states=("1", "2", "3", "4"), + states=("1", "2", "3", "4", "6", "7", "8", "9", "10"), device_class=BinarySensorDeviceClass.MOISTURE, ), + NotificationZWaveJSEntityDescription( + # NotificationType 5: Water - State Id's B + key=NOTIFICATION_WATER, + states=("11",), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 5: Water - All other State Id's key=NOTIFICATION_WATER, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock) @@ -214,16 +287,22 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = device_class=BinarySensorDeviceClass.SOUND, ), NotificationZWaveJSEntityDescription( - # NotificationType 18: Gas + # NotificationType 18: Gas - State Id's 1, 2, 3, 4 key=NOTIFICATION_GAS, states=("1", "2", "3", "4"), device_class=BinarySensorDeviceClass.GAS, ), NotificationZWaveJSEntityDescription( - # NotificationType 18: Gas + # NotificationType 18: Gas - State Id 6 key=NOTIFICATION_GAS, states=("6",), device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 18: Gas - All other State Id's + key=NOTIFICATION_GAS, + entity_category=EntityCategory.DIAGNOSTIC, ), ) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index aed0dd839be..d95f3208e17 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -21,19 +21,16 @@ from homeassistant.components.hassio import ( ) from homeassistant.config_entries import ( SOURCE_USB, - ConfigEntriesFlowManager, ConfigEntry, ConfigEntryBaseFlow, ConfigEntryState, ConfigFlow, - ConfigFlowContext, ConfigFlowResult, OptionsFlow, - OptionsFlowManager, ) from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import AbortFlow, FlowManager +from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio @@ -191,11 +188,6 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): self.start_task: asyncio.Task | None = None self.version_info: VersionInfo | None = None - @property - @abstractmethod - def flow_manager(self) -> FlowManager[ConfigFlowContext, ConfigFlowResult]: - """Return the flow manager of the flow.""" - async def async_step_install_addon( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -355,11 +347,6 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): self.use_addon = False self._usb_discovery = False - @property - def flow_manager(self) -> ConfigEntriesFlowManager: - """Return the correct flow manager.""" - return self.hass.config_entries.flow - @staticmethod @callback def async_get_options_flow( @@ -729,11 +716,6 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): self.original_addon_config: dict[str, Any] | None = None self.revert_reason: str | None = None - @property - def flow_manager(self) -> OptionsFlowManager: - """Return the correct flow manager.""" - return self.hass.config_entries.options - @callback def _async_update_entry(self, data: dict[str, Any]) -> None: """Update the config entry with new data.""" diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index a610bbcb91e..f60e129cc77 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( @@ -483,7 +483,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): red = multi_color.get(COLOR_SWITCH_COMBINED_RED, red_val.value) green = multi_color.get(COLOR_SWITCH_COMBINED_GREEN, green_val.value) blue = multi_color.get(COLOR_SWITCH_COMBINED_BLUE, blue_val.value) - if None not in (red, green, blue): + if red is not None and green is not None and blue is not None: # convert to HS self._hs_color = color_util.color_RGB_to_hs(red, green, blue) # Light supports color, set color mode to hs @@ -496,7 +496,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # Calculate color temps based on whites if cold_white or warm_white: self._color_temp = color_util.color_temperature_mired_to_kelvin( - MAX_MIREDS - ((cold_white / 255) * (MAX_MIREDS - MIN_MIREDS)) + MAX_MIREDS + - ((cast(int, cold_white) / 255) * (MAX_MIREDS - MIN_MIREDS)) ) # White channels turned on, set color mode to color_temp self._color_mode = ColorMode.COLOR_TEMP @@ -505,6 +506,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # only one white channel (warm white) = rgbw support elif red_val and green_val and blue_val and ww_val: white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value) + if TYPE_CHECKING: + assert ( + red is not None + and green is not None + and blue is not None + and white is not None + ) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw self._color_mode = ColorMode.RGBW @@ -512,6 +520,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): elif cw_val: self._supports_rgbw = True white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value) + if TYPE_CHECKING: + assert ( + red is not None + and green is not None + and blue is not None + and white is not None + ) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw self._color_mode = ColorMode.RGBW diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index d5c5a69cb96..43a39de29c5 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave-me-ws==0.4.3", "url-normalize==1.4.3"], + "requirements": ["zwave-me-ws==0.4.3", "url-normalize==2.2.0"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 9336ead633a..ef1865da4be 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -30,7 +30,6 @@ from propcache.api import cached_property import voluptuous as vol from . import data_entry_flow, loader -from .components import persistent_notification from .const import ( CONF_NAME, EVENT_HOMEASSISTANT_STARTED, @@ -178,7 +177,6 @@ class ConfigEntryState(Enum): DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id" -DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" DISCOVERY_SOURCES = { SOURCE_BLUETOOTH, SOURCE_DHCP, @@ -195,8 +193,6 @@ DISCOVERY_SOURCES = { SOURCE_ZEROCONF, } -RECONFIGURE_NOTIFICATION_ID = "config_entry_reconfigure" - EVENT_FLOW_DISCOVERED = "config_entry_discovered" SIGNAL_CONFIG_ENTRY_CHANGED = SignalType["ConfigEntryChange", "ConfigEntry"]( @@ -1371,12 +1367,12 @@ class ConfigEntriesFlowManager( self._initialize_futures: defaultdict[str, set[asyncio.Future[None]]] = ( defaultdict(set) ) - self._discovery_debouncer = Debouncer[None]( + self._discovery_event_debouncer = Debouncer[None]( hass, _LOGGER, cooldown=DISCOVERY_COOLDOWN, immediate=True, - function=self._async_discovery, + function=self._async_fire_discovery_event, background=True, ) @@ -1387,14 +1383,6 @@ class ConfigEntriesFlowManager( await asyncio.wait(current.values()) - @callback - def _async_has_other_discovery_flows(self, flow_id: str) -> bool: - """Check if there are any other discovery flows in progress.""" - for flow in self._progress.values(): - if flow.flow_id != flow_id and flow.context["source"] in DISCOVERY_SOURCES: - return True - return False - async def async_init( self, handler: str, @@ -1466,8 +1454,12 @@ class ConfigEntriesFlowManager( if not self._pending_import_flows[handler]: del self._pending_import_flows[handler] - if result["type"] != data_entry_flow.FlowResultType.ABORT: - await self.async_post_init(flow, result) + if ( + result["type"] != data_entry_flow.FlowResultType.ABORT + and source in DISCOVERY_SOURCES + ): + # Fire discovery event + await self._discovery_event_debouncer.async_call() return result @@ -1509,7 +1501,7 @@ class ConfigEntriesFlowManager( for future_list in self._initialize_futures.values(): for future in future_list: future.set_result(None) - self._discovery_debouncer.async_shutdown() + self._discovery_event_debouncer.async_shutdown() async def async_finish_flow( self, @@ -1529,10 +1521,6 @@ class ConfigEntriesFlowManager( # init to be done. self._set_pending_import_done(flow) - # Remove notification if no other discovery config entries in progress - if not self._async_has_other_discovery_flows(flow.flow_id): - persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_ID) - # Clean up issue if this is a reauth flow if flow.context["source"] == SOURCE_REAUTH: if (entry_id := flow.context.get("entry_id")) is not None and ( @@ -1542,7 +1530,7 @@ class ConfigEntriesFlowManager( ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: - # If there's an ignored config entry with a matching unique ID, + # If there's a config entry with a matching unique ID, # update the discovery key. if ( (discovery_key := flow.context.get("discovery_key")) @@ -1628,7 +1616,11 @@ class ConfigEntriesFlowManager( result["handler"], flow.unique_id ) - if existing_entry is not None and flow.handler != "mobile_app": + if ( + existing_entry is not None + and flow.handler != "mobile_app" + and existing_entry.source != SOURCE_IGNORE + ): # This causes the old entry to be removed and replaced, when the flow # should instead be aborted. # In case of manual flows, integrations should implement options, reauth, @@ -1703,43 +1695,12 @@ class ConfigEntriesFlowManager( flow.init_step = context["source"] return flow - async def async_post_init( - self, - flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], - result: ConfigFlowResult, - ) -> None: - """After a flow is initialised trigger new flow notifications.""" - source = flow.context["source"] - - # Create notification. - if source in DISCOVERY_SOURCES: - await self._discovery_debouncer.async_call() - elif source == SOURCE_REAUTH: - persistent_notification.async_create( - self.hass, - title="Integration requires reconfiguration", - message=( - "At least one of your integrations requires reconfiguration to " - "continue functioning. [Check it out](/config/integrations)." - ), - notification_id=RECONFIGURE_NOTIFICATION_ID, - ) - @callback - def _async_discovery(self) -> None: - """Handle discovery.""" + def _async_fire_discovery_event(self) -> None: + """Fire discovery event.""" # async_fire_internal is used here because this is only # called from the Debouncer so we know the usage is safe self.hass.bus.async_fire_internal(EVENT_FLOW_DISCOVERED) - persistent_notification.async_create( - self.hass, - title="New devices discovered", - message=( - "We have discovered new devices on your network. " - "[Check it out](/config/integrations)." - ), - notification_id=DISCOVERY_NOTIFICATION_ID, - ) @callback def async_has_matching_discovery_flow( @@ -3119,29 +3080,6 @@ class ConfigFlow(ConfigEntryBaseFlow): """Handle a flow initialized by discovery.""" return await self._async_step_discovery_without_unique_id() - @callback - def async_abort( - self, - *, - reason: str, - description_placeholders: Mapping[str, str] | None = None, - ) -> ConfigFlowResult: - """Abort the config flow.""" - # Remove reauth notification if no reauth flows are in progress - if self.source == SOURCE_REAUTH and not any( - ent["flow_id"] != self.flow_id - for ent in self.hass.config_entries.flow.async_progress_by_handler( - self.handler, match_context={"source": SOURCE_REAUTH} - ) - ): - persistent_notification.async_dismiss( - self.hass, RECONFIGURE_NOTIFICATION_ID - ) - - return super().async_abort( - reason=reason, description_placeholders=description_placeholders - ) - async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak ) -> ConfigFlowResult: @@ -3491,18 +3429,14 @@ class ConfigSubentryFlow( return self.async_abort(reason="reconfigure_successful") @property - def _reconfigure_entry_id(self) -> str: - """Return reconfigure entry id.""" - if self.source != SOURCE_RECONFIGURE: - raise ValueError(f"Source is {self.source}, expected {SOURCE_RECONFIGURE}") + def _entry_id(self) -> str: + """Return config entry id.""" return self.handler[0] @callback - def _get_reconfigure_entry(self) -> ConfigEntry: - """Return the reconfigure config entry linked to the current context.""" - return self.hass.config_entries.async_get_known_entry( - self._reconfigure_entry_id - ) + def _get_entry(self) -> ConfigEntry: + """Return the config entry linked to the current context.""" + return self.hass.config_entries.async_get_known_entry(self._entry_id) @property def _reconfigure_subentry_id(self) -> str: @@ -3514,9 +3448,7 @@ class ConfigSubentryFlow( @callback def _get_reconfigure_subentry(self) -> ConfigSubentry: """Return the reconfigure config subentry linked to the current context.""" - entry = self.hass.config_entries.async_get_known_entry( - self._reconfigure_entry_id - ) + entry = self.hass.config_entries.async_get_known_entry(self._entry_id) subentry_id = self._reconfigure_subentry_id if subentry_id not in entry.subentries: raise UnknownSubEntry(subentry_id) diff --git a/homeassistant/const.py b/homeassistant/const.py index b9695c350a7..a6f39db8532 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 4 +MINOR_VERSION: Final = 5 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/homeassistant/core.py b/homeassistant/core.py index 46ae499e2ca..b33e9496c7c 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -38,6 +38,7 @@ from typing import ( TypedDict, TypeVar, cast, + final, overload, ) @@ -324,6 +325,7 @@ class HassJobType(enum.Enum): Executor = 3 +@final # Final to allow direct checking of the type instead of using isinstance class HassJob[**_P, _R_co]: """Represent a job to be run later. @@ -1317,6 +1319,7 @@ class EventOrigin(enum.Enum): return next((idx for idx, origin in enumerate(EventOrigin) if origin is self)) +@final # Final to allow direct checking of the type instead of using isinstance class Event(Generic[_DataT]): """Representation of an event within the bus.""" @@ -1935,13 +1938,14 @@ class State: # to avoid callers outside of this module # from misusing it by mistake. context = state_context._as_dict # noqa: SLF001 + last_changed_timestamp = self.last_changed_timestamp compressed_state: CompressedState = { COMPRESSED_STATE_STATE: self.state, COMPRESSED_STATE_ATTRIBUTES: self.attributes, COMPRESSED_STATE_CONTEXT: context, - COMPRESSED_STATE_LAST_CHANGED: self.last_changed_timestamp, + COMPRESSED_STATE_LAST_CHANGED: last_changed_timestamp, } - if self.last_changed != self.last_updated: + if last_changed_timestamp != self.last_updated_timestamp: compressed_state[COMPRESSED_STATE_LAST_UPDATED] = ( self.last_updated_timestamp ) diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index f080705fced..9cd232097a7 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -581,9 +581,7 @@ class Config: self.all_components: set[str] = set() # Set of loaded components - self.components: _ComponentSet = _ComponentSet( - self.top_level_components, self.all_components - ) + self.components = _ComponentSet(self.top_level_components, self.all_components) # API (HTTP) server configuration self.api: ApiConfig | None = None diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 251e22e7990..511bab25a7f 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -219,13 +219,6 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): FlowResultType.CREATE_ENTRY. """ - async def async_post_init( - self, - flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT], - result: _FlowResultT, - ) -> None: - """Entry has finished executing its first step asynchronously.""" - @callback def async_get(self, flow_id: str) -> _FlowResultT: """Return a flow in progress as a partial FlowResult.""" @@ -312,12 +305,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow.init_data = data self._async_add_flow_progress(flow) - result = await self._async_handle_step(flow, flow.init_step, data) - - if result["type"] != FlowResultType.ABORT: - await self.async_post_init(flow, result) - - return result + return await self._async_handle_step(flow, flow.init_step, data) async def async_configure( self, flow_id: str, user_input: dict | None = None @@ -657,6 +645,19 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): ): continue + # Process the section schema options + if ( + suggested_values is not None + and isinstance(val, section) + and key in suggested_values + ): + new_section_key = copy.copy(key) + schema[new_section_key] = val + val.schema = self.add_suggested_values_to_schema( + val.schema, suggested_values[key] + ) + continue + new_key = key if ( suggested_values diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 68c6de405e6..eaa4c657b56 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -19,6 +19,7 @@ APPLICATION_CREDENTIALS = [ "iotty", "lametric", "lyric", + "mcp", "microbees", "monzo", "myuplink", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a9c4a6b0a93..d192b8fcd13 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -91,6 +91,7 @@ FLOWS = { "bluetooth", "bmw_connected_drive", "bond", + "bosch_alarm", "bosch_shc", "braviatv", "bring", @@ -488,6 +489,7 @@ FLOWS = { "proximity", "prusalink", "ps4", + "pterodactyl", "pure_energie", "purpleair", "pushbullet", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 3dba5a98f3c..9a8fd349a8b 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -84,6 +84,16 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "blink*", "macaddress": "20A171*", }, + { + "domain": "bond", + "hostname": "bond-*", + "macaddress": "3C6A2C1*", + }, + { + "domain": "bond", + "hostname": "bond-*", + "macaddress": "F44E38*", + }, { "domain": "broadlink", "registered_devices": True, @@ -498,6 +508,18 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "ring*", "macaddress": "341513*", }, + { + "domain": "roborock", + "macaddress": "249E7D*", + }, + { + "domain": "roborock", + "macaddress": "B04A39*", + }, + { + "domain": "roborock", + "hostname": "roborock-*", + }, { "domain": "roomba", "hostname": "irobot-*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 55fcb08ba92..d0f0efe8ded 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -611,6 +611,13 @@ "config_flow": true, "iot_class": "local_push" }, + "backup": { + "name": "Backup", + "integration_type": "service", + "config_flow": false, + "iot_class": "calculated", + "single_config_entry": true + }, "baf": { "name": "Big Ass Fans", "integration_type": "hub", @@ -623,6 +630,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "balay": { + "name": "Balay", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "balboa": { "name": "Balboa Spa Client", "integration_type": "hub", @@ -752,11 +764,28 @@ "config_flow": true, "iot_class": "local_push" }, - "bosch_shc": { - "name": "Bosch SHC", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "bosch": { + "name": "Bosch", + "integrations": { + "bosch_alarm": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push", + "name": "Bosch Alarm" + }, + "bosch_shc": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Bosch SHC" + }, + "home_connect": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push", + "name": "Home Connect" + } + } }, "brandt": { "name": "Brandt Smart Control", @@ -1042,6 +1071,11 @@ "integration_type": "virtual", "supported_by": "opower" }, + "constructa": { + "name": "Constructa", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "control4": { "name": "Control4", "integration_type": "hub", @@ -1791,6 +1825,12 @@ } } }, + "eve": { + "name": "Eve", + "iot_standards": [ + "matter" + ] + }, "evergy": { "name": "Evergy", "integration_type": "virtual", @@ -2150,6 +2190,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "gaggenau": { + "name": "Gaggenau", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "garadget": { "name": "Garadget", "integration_type": "hub", @@ -2489,6 +2534,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "hardkernel": { + "name": "Hardkernel", + "integration_type": "hardware", + "config_flow": false, + "single_config_entry": true + }, "harman_kardon_avr": { "name": "Harman Kardon AVR", "integration_type": "hub", @@ -2620,18 +2671,28 @@ "config_flow": true, "iot_class": "local_polling" }, - "home_connect": { - "name": "Home Connect", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_push", - "single_config_entry": true - }, "home_plus_control": { "name": "Legrand Home+ Control", "integration_type": "virtual", "supported_by": "netatmo" }, + "homeassistant_green": { + "name": "Home Assistant Green", + "integration_type": "hardware", + "config_flow": false, + "single_config_entry": true + }, + "homeassistant_sky_connect": { + "name": "Home Assistant Connect ZBT-1", + "integration_type": "hardware", + "config_flow": true + }, + "homeassistant_yellow": { + "name": "Home Assistant Yellow", + "integration_type": "hardware", + "config_flow": false, + "single_config_entry": true + }, "homee": { "name": "Homee", "integration_type": "hub", @@ -4000,7 +4061,10 @@ "iot_class": "assumed_state", "name": "Motionblinds Bluetooth" } - } + }, + "iot_standards": [ + "matter" + ] }, "motioneye": { "name": "motionEye", @@ -4158,6 +4222,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "neff": { + "name": "Neff", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "ness_alarm": { "name": "Ness Alarm", "integration_type": "hub", @@ -4851,6 +4920,11 @@ "integration_type": "virtual", "supported_by": "wyoming" }, + "pitsos": { + "name": "Pitsos", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "pjlink": { "name": "PJLink", "integration_type": "hub", @@ -4926,6 +5000,11 @@ "config_flow": true, "single_config_entry": true }, + "profilo": { + "name": "Profilo", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "progettihwsw": { "name": "ProgettiHWSW Automation", "integration_type": "hub", @@ -4988,6 +5067,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "pterodactyl": { + "name": "Pterodactyl", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "pulseaudio_loopback": { "name": "PulseAudio Loopback", "integration_type": "hub", @@ -5192,6 +5277,11 @@ "raspberry_pi": { "name": "Raspberry Pi", "integrations": { + "raspberry_pi": { + "integration_type": "hardware", + "config_flow": false, + "name": "Raspberry Pi" + }, "rpi_camera": { "integration_type": "hub", "config_flow": false, @@ -5727,6 +5817,11 @@ "config_flow": true, "iot_class": "local_push" }, + "siemens": { + "name": "Siemens", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "sigfox": { "name": "Sigfox", "integration_type": "hub", @@ -6479,6 +6574,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "thermador": { + "name": "Thermador", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "thermobeacon": { "name": "ThermoBeacon", "integration_type": "hub", diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 5601ce4032d..ba02ed51f6b 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -20,6 +20,7 @@ from .json import json_bytes, json_fragment from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, NormalizedNameBaseRegistryItems, + normalize_name, ) from .registry import BaseRegistry, RegistryIndexType from .singleton import singleton @@ -169,6 +170,7 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): super().__init__() self._labels_index: RegistryIndexType = defaultdict(dict) self._floors_index: RegistryIndexType = defaultdict(dict) + self._aliases_index: RegistryIndexType = defaultdict(dict) def _index_entry(self, key: str, entry: AreaEntry) -> None: """Index an entry.""" @@ -177,6 +179,9 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): self._floors_index[entry.floor_id][key] = True for label in entry.labels: self._labels_index[label][key] = True + for alias in entry.aliases: + normalized_alias = normalize_name(alias) + self._aliases_index[normalized_alias][key] = True def _unindex_entry( self, key: str, replacement_entry: AreaEntry | None = None @@ -184,6 +189,10 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): # always call base class before other indices super()._unindex_entry(key, replacement_entry) entry = self.data[key] + if aliases := entry.aliases: + for alias in aliases: + normalized_alias = normalize_name(alias) + self._unindex_entry_value(key, normalized_alias, self._aliases_index) if labels := entry.labels: for label in labels: self._unindex_entry_value(key, label, self._labels_index) @@ -200,6 +209,12 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): data = self.data return [data[key] for key in self._floors_index.get(floor, ())] + def get_areas_for_alias(self, alias: str) -> list[AreaEntry]: + """Get areas for alias.""" + data = self.data + normalized_alias = normalize_name(alias) + return [data[key] for key in self._aliases_index.get(normalized_alias, ())] + class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): """Class to hold a registry of areas.""" @@ -232,6 +247,11 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): """Get area by name.""" return self.areas.get_by_name(name) + @callback + def async_get_areas_by_alias(self, alias: str) -> list[AreaEntry]: + """Get areas by alias.""" + return self.areas.get_areas_for_alias(alias) + @callback def async_list_areas(self) -> Iterable[AreaEntry]: """Get all areas.""" diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py index 4ab302749a1..b3607f6653c 100644 --- a/homeassistant/helpers/backup.py +++ b/homeassistant/helpers/backup.py @@ -12,7 +12,11 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: - from homeassistant.components.backup import BackupManager, ManagerStateEvent + from homeassistant.components.backup import ( + BackupManager, + BackupPlatformEvent, + ManagerStateEvent, + ) DATA_BACKUP: HassKey[BackupData] = HassKey("backup_data") DATA_MANAGER: HassKey[BackupManager] = HassKey("backup") @@ -25,6 +29,9 @@ class BackupData: backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = field( default_factory=list ) + backup_platform_event_subscriptions: list[Callable[[BackupPlatformEvent], None]] = ( + field(default_factory=list) + ) manager_ready: asyncio.Future[None] = field(default_factory=asyncio.Future) @@ -68,3 +75,20 @@ def async_subscribe_events( backup_event_subscriptions.append(on_event) return remove_subscription + + +@callback +def async_subscribe_platform_events( + hass: HomeAssistant, + on_event: Callable[[BackupPlatformEvent], None], +) -> Callable[[], None]: + """Subscribe to backup platform events.""" + backup_platform_event_subscriptions = hass.data[ + DATA_BACKUP + ].backup_platform_event_subscriptions + + def remove_subscription() -> None: + backup_platform_event_subscriptions.remove(on_event) + + backup_platform_event_subscriptions.append(on_event) + return remove_subscription diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 84728978ede..1cff90031c2 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -27,11 +27,11 @@ import voluptuous as vol from yarl import URL from homeassistant import config_entries -from homeassistant.components import http from homeassistant.core import HomeAssistant, callback from homeassistant.loader import async_get_application_credentials from homeassistant.util.hass_dict import HassKey +from . import http from .aiohttp_client import async_get_clientsession from .network import NoURLAvailableError diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 4978158c0f6..5c1a7c99565 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1153,41 +1153,6 @@ def _custom_serializer(schema: Any, *, allow_section: bool) -> Any: return voluptuous_serialize.UNSUPPORTED -def expand_condition_shorthand(value: Any | None) -> Any: - """Expand boolean condition shorthand notations.""" - - if not isinstance(value, dict) or CONF_CONDITIONS in value: - return value - - for key, schema in ( - ("and", AND_CONDITION_SHORTHAND_SCHEMA), - ("or", OR_CONDITION_SHORTHAND_SCHEMA), - ("not", NOT_CONDITION_SHORTHAND_SCHEMA), - ): - try: - schema(value) - return { - CONF_CONDITION: key, - CONF_CONDITIONS: value[key], - **{k: value[k] for k in value if k != key}, - } - except vol.MultipleInvalid: - pass - - if isinstance(value.get(CONF_CONDITION), list): - try: - CONDITION_SHORTHAND_SCHEMA(value) - return { - CONF_CONDITION: "and", - CONF_CONDITIONS: value[CONF_CONDITION], - **{k: value[k] for k in value if k != CONF_CONDITION}, - } - except vol.MultipleInvalid: - pass - - return value - - # Schemas def empty_config_schema(domain: str) -> Callable[[dict], dict]: """Return a config schema which logs if there are configuration parameters.""" @@ -1683,7 +1648,43 @@ DEVICE_CONDITION_BASE_SCHEMA = vol.Schema( DEVICE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -dynamic_template_condition_action = vol.All( + +def expand_condition_shorthand(value: Any | None) -> Any: + """Expand boolean condition shorthand notations.""" + + if not isinstance(value, dict) or CONF_CONDITIONS in value: + return value + + for key, schema in ( + ("and", AND_CONDITION_SHORTHAND_SCHEMA), + ("or", OR_CONDITION_SHORTHAND_SCHEMA), + ("not", NOT_CONDITION_SHORTHAND_SCHEMA), + ): + try: + schema(value) + return { + CONF_CONDITION: key, + CONF_CONDITIONS: value[key], + **{k: value[k] for k in value if k != key}, + } + except vol.MultipleInvalid: + pass + + if isinstance(value.get(CONF_CONDITION), list): + try: + CONDITION_SHORTHAND_SCHEMA(value) + return { + CONF_CONDITION: "and", + CONF_CONDITIONS: value[CONF_CONDITION], + **{k: value[k] for k in value if k != CONF_CONDITION}, + } + except vol.MultipleInvalid: + pass + + return value + + +dynamic_template_condition = vol.All( # Wrap a shorthand template condition in a template condition dynamic_template, lambda config: { @@ -1724,7 +1725,7 @@ CONDITION_SCHEMA: vol.Schema = vol.Schema( }, ), ), - dynamic_template_condition_action, + dynamic_template_condition, ) ) @@ -1873,12 +1874,8 @@ _SCRIPT_REPEAT_SCHEMA = vol.Schema( vol.Exclusive(CONF_FOR_EACH, "repeat"): vol.Any( dynamic_template, vol.All(list, template_complex) ), - vol.Exclusive(CONF_WHILE, "repeat"): vol.All( - ensure_list, [CONDITION_SCHEMA] - ), - vol.Exclusive(CONF_UNTIL, "repeat"): vol.All( - ensure_list, [CONDITION_SCHEMA] - ), + vol.Exclusive(CONF_WHILE, "repeat"): CONDITIONS_SCHEMA, + vol.Exclusive(CONF_UNTIL, "repeat"): CONDITIONS_SCHEMA, vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA, }, has_at_least_one_key(CONF_COUNT, CONF_FOR_EACH, CONF_WHILE, CONF_UNTIL), @@ -1894,9 +1891,7 @@ _SCRIPT_CHOOSE_SCHEMA = vol.Schema( [ { vol.Optional(CONF_ALIAS): string, - vol.Required(CONF_CONDITIONS): vol.All( - ensure_list, [CONDITION_SCHEMA] - ), + vol.Required(CONF_CONDITIONS): CONDITIONS_SCHEMA, vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA, } ], @@ -1917,7 +1912,7 @@ _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA = vol.Schema( _SCRIPT_IF_SCHEMA = vol.Schema( { **SCRIPT_ACTION_BASE_SCHEMA, - vol.Required(CONF_IF): vol.All(ensure_list, [CONDITION_SCHEMA]), + vol.Required(CONF_IF): CONDITIONS_SCHEMA, vol.Required(CONF_THEN): SCRIPT_SCHEMA, vol.Optional(CONF_ELSE): SCRIPT_SCHEMA, } diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 991a6cf5a57..79d6774c407 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -581,8 +581,8 @@ class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( def get_entry( self, - identifiers: set[tuple[str, str]] | None, - connections: set[tuple[str, str]] | None, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, ) -> _EntryTypeT | None: """Get entry from identifiers or connections.""" if identifiers: @@ -709,22 +709,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Check if device is registered.""" return self.devices.get_entry(identifiers, connections) - def _async_get_deleted_device( - self, - identifiers: set[tuple[str, str]], - connections: set[tuple[str, str]], - ) -> DeletedDeviceEntry | None: - """Check if device is deleted.""" - return self.deleted_devices.get_entry(identifiers, connections) - - def _async_get_deleted_devices( - self, - identifiers: set[tuple[str, str]] | None = None, - connections: set[tuple[str, str]] | None = None, - ) -> Iterable[DeletedDeviceEntry]: - """List devices that are deleted.""" - return self.deleted_devices.get_entries(identifiers, connections) - def _substitute_name_placeholders( self, domain: str, @@ -839,10 +823,12 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): else: connections = _normalize_connections(connections) - device = self.async_get_device(identifiers=identifiers, connections=connections) + device = self.devices.get_entry( + identifiers=identifiers, connections=connections + ) if device is None: - deleted_device = self._async_get_deleted_device(identifiers, connections) + deleted_device = self.deleted_devices.get_entry(identifiers, connections) if deleted_device is None: device = DeviceEntry(is_new=True) else: @@ -869,7 +855,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): name = default_name if via_device is not None and via_device is not UNDEFINED: - if (via := self.async_get_device(identifiers={via_device})) is None: + if (via := self.devices.get_entry(identifiers={via_device})) is None: report_usage( "calls `device_registry.async_get_or_create` referencing a " f"non existing `via_device` {via_device}, " @@ -1172,7 +1158,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # NOTE: Once we solve the broader issue of duplicated devices, we might # want to revisit it. Instead of simply removing the duplicated deleted device, # we might want to merge the information from it into the non-deleted device. - for deleted_device in self._async_get_deleted_devices( + for deleted_device in self.deleted_devices.get_entries( added_identifiers, added_connections ): del self.deleted_devices[deleted_device.id] @@ -1214,7 +1200,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.async_get_device(connections={connection}) + existing_device := self.devices.get_entry(connections={connection}) ) and existing_device.id != device_id: raise DeviceConnectionCollisionError( normalized_connections, existing_device @@ -1238,7 +1224,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.async_get_device(identifiers={identifier}) + existing_device := self.devices.get_entry(identifiers={identifier}) ) and existing_device.id != device_id: raise DeviceIdentifierCollisionError(identifiers, existing_device) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 11a9786f86e..2ca331a185b 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -573,9 +573,9 @@ class EntityPlatform: async def _async_add_and_update_entities( self, - coros: list[Coroutine[Any, Any, None]], entities: list[Entity], timeout: float, + config_subentry_id: str | None, ) -> None: """Add entities for a single platform and update them. @@ -585,10 +585,21 @@ class EntityPlatform: event loop and will finish faster if we run them concurrently. """ results: list[BaseException | None] | None = None - tasks = [create_eager_task(coro, loop=self.hass.loop) for coro in coros] + entity_registry = ent_reg.async_get(self.hass) try: async with self.hass.timeout.async_timeout(timeout, self.domain): - results = await asyncio.gather(*tasks, return_exceptions=True) + results = await asyncio.gather( + *( + create_eager_task( + self._async_add_entity( + entity, True, entity_registry, config_subentry_id + ), + loop=self.hass.loop, + ) + for entity in entities + ), + return_exceptions=True, + ) except TimeoutError: self.logger.warning( "Timed out adding entities for domain %s with platform %s after %ds", @@ -615,9 +626,9 @@ class EntityPlatform: async def _async_add_entities( self, - coros: list[Coroutine[Any, Any, None]], entities: list[Entity], timeout: float, + config_subentry_id: str | None, ) -> None: """Add entities for a single platform without updating. @@ -626,13 +637,15 @@ class EntityPlatform: to the event loop so we can await the coros directly without scheduling them as tasks. """ + entity_registry = ent_reg.async_get(self.hass) try: async with self.hass.timeout.async_timeout(timeout, self.domain): - for idx, coro in enumerate(coros): + for entity in entities: try: - await coro + await self._async_add_entity( + entity, False, entity_registry, config_subentry_id + ) except Exception as ex: - entity = entities[idx] self.logger.exception( "Error adding entity %s for domain %s with platform %s", entity.entity_id, @@ -670,33 +683,20 @@ class EntityPlatform: f"entry {self.config_entry.entry_id if self.config_entry else None}" ) + entities: list[Entity] = ( + new_entities if type(new_entities) is list else list(new_entities) + ) # handle empty list from component/platform - if not new_entities: # type: ignore[truthy-iterable] + if not entities: return - hass = self.hass - entity_registry = ent_reg.async_get(hass) - coros: list[Coroutine[Any, Any, None]] = [] - entities: list[Entity] = [] - for entity in new_entities: - coros.append( - self._async_add_entity( - entity, update_before_add, entity_registry, config_subentry_id - ) - ) - entities.append(entity) - - # No entities for processing - if not coros: - return - - timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(coros), SLOW_ADD_MIN_TIMEOUT) + timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(entities), SLOW_ADD_MIN_TIMEOUT) if update_before_add: - add_func = self._async_add_and_update_entities + await self._async_add_and_update_entities( + entities, timeout, config_subentry_id + ) else: - add_func = self._async_add_entities - - await add_func(coros, entities, timeout) + await self._async_add_entities(entities, timeout, config_subentry_id) if ( (self.config_entry and self.config_entry.pref_disable_polling) diff --git a/homeassistant/helpers/floor_registry.py b/homeassistant/helpers/floor_registry.py index fcfca8e3212..186ad2b31f7 100644 --- a/homeassistant/helpers/floor_registry.py +++ b/homeassistant/helpers/floor_registry.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Iterable import dataclasses from dataclasses import dataclass @@ -16,8 +17,9 @@ from homeassistant.util.hass_dict import HassKey from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, NormalizedNameBaseRegistryItems, + normalize_name, ) -from .registry import BaseRegistry +from .registry import BaseRegistry, RegistryIndexType from .singleton import singleton from .storage import Store from .typing import UNDEFINED, UndefinedType @@ -92,10 +94,43 @@ class FloorRegistryStore(Store[FloorRegistryStoreData]): return old_data # type: ignore[return-value] +class FloorRegistryItems(NormalizedNameBaseRegistryItems[FloorEntry]): + """Class to hold floor registry items.""" + + def __init__(self) -> None: + """Initialize the floor registry items.""" + super().__init__() + self._aliases_index: RegistryIndexType = defaultdict(dict) + + def _index_entry(self, key: str, entry: FloorEntry) -> None: + """Index an entry.""" + super()._index_entry(key, entry) + for alias in entry.aliases: + normalized_alias = normalize_name(alias) + self._aliases_index[normalized_alias][key] = True + + def _unindex_entry( + self, key: str, replacement_entry: FloorEntry | None = None + ) -> None: + # always call base class before other indices + super()._unindex_entry(key, replacement_entry) + entry = self.data[key] + if aliases := entry.aliases: + for alias in aliases: + normalized_alias = normalize_name(alias) + self._unindex_entry_value(key, normalized_alias, self._aliases_index) + + def get_floors_for_alias(self, alias: str) -> list[FloorEntry]: + """Get floors for alias.""" + data = self.data + normalized_alias = normalize_name(alias) + return [data[key] for key in self._aliases_index.get(normalized_alias, ())] + + class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): """Class to hold a registry of floors.""" - floors: NormalizedNameBaseRegistryItems[FloorEntry] + floors: FloorRegistryItems _floor_data: dict[str, FloorEntry] def __init__(self, hass: HomeAssistant) -> None: @@ -123,6 +158,11 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): """Get floor by name.""" return self.floors.get_by_name(name) + @callback + def async_get_floors_by_alias(self, alias: str) -> list[FloorEntry]: + """Get floors by alias.""" + return self.floors.get_floors_for_alias(alias) + @callback def async_list_floors(self) -> Iterable[FloorEntry]: """Get all floors.""" @@ -226,7 +266,7 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): async def async_load(self) -> None: """Load the floor registry.""" data = await self._store.async_load() - floors = NormalizedNameBaseRegistryItems[FloorEntry]() + floors = FloorRegistryItems() if data is not None: for floor in data["floors"]: diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index ade2ce747d5..49b12e0aa60 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -7,6 +7,9 @@ import sys from types import TracebackType from typing import Any, Self +# httpx dynamically imports httpcore, so we need to import it +# to avoid it being imported later when the event loop is running +import httpcore # noqa: F401 import httpx from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 5995543914f..aa6b3dc2cbf 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -9,6 +9,7 @@ from datetime import timedelta from decimal import Decimal from enum import Enum from functools import cache, partial +from operator import attrgetter from typing import Any, cast import slugify as unicode_slug @@ -316,7 +317,7 @@ class AssistAPI(API): """Return the instance of the API.""" if llm_context.assistant: exposed_entities: dict | None = _get_exposed_entities( - self.hass, llm_context.assistant + self.hass, llm_context.assistant, include_state=False ) else: exposed_entities = None @@ -463,7 +464,9 @@ class AssistAPI(API): def _get_exposed_entities( - hass: HomeAssistant, assistant: str + hass: HomeAssistant, + assistant: str, + include_state: bool = True, ) -> dict[str, dict[str, dict[str, Any]]]: """Get exposed entities. @@ -494,7 +497,7 @@ def _get_exposed_entities( CALENDAR_DOMAIN: {}, } - for state in hass.states.async_all(): + for state in sorted(hass.states.async_all(), key=attrgetter("name")): if not async_should_expose(hass, assistant, state.entity_id): continue @@ -524,24 +527,28 @@ def _get_exposed_entities( info: dict[str, Any] = { "names": ", ".join(names), "domain": state.domain, - "state": state.state, } + if include_state: + info["state"] = state.state + if description: info["description"] = description if area_names: info["areas"] = ", ".join(area_names) - if attributes := { - attr_name: ( - str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value - ) - for attr_name, attr_value in state.attributes.items() - if attr_name in interesting_attributes - }: + if include_state and ( + attributes := { + attr_name: ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) + for attr_name, attr_value in state.attributes.items() + if attr_name in interesting_attributes + } + ): info["attributes"] = attributes if state.domain in data: diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index e39cc2de547..67c4448724e 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -10,12 +10,12 @@ from aiohttp import hdrs from hass_nabucasa import remote import yarl -from homeassistant.components import http from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.util.network import is_ip_address, is_loopback, normalize_url +from . import http from .hassio import is_hassio TYPE_URL_INTERNAL = "internal_url" diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index bf7a4a0971c..43429bdb1d2 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -966,12 +966,11 @@ class _ScriptRun: ## Variable actions ## async def _async_step_variables(self) -> None: - """Define a local variable.""" - self._step_log("defining local variables") - for key, value in ( - self._action[CONF_VARIABLES].async_simple_render(self._variables).items() - ): - self._variables.define_local(key, value) + """Assign values to variables.""" + self._step_log("assigning variables") + self._variables.update( + self._action[CONF_VARIABLES].async_simple_render(self._variables) + ) ## External actions ## @@ -1312,7 +1311,7 @@ class _QueuedScriptRun(_ScriptRun): lock_acquired = False - async def async_run(self) -> None: + async def async_run(self) -> ScriptRunResult | None: """Run script.""" # Wait for previous run, if any, to finish by attempting to acquire the script's # shared lock. At the same time monitor if we've been told to stop. @@ -1326,7 +1325,7 @@ class _QueuedScriptRun(_ScriptRun): self.lock_acquired = True # We've acquired the lock so we can go ahead and start the run. - await super().async_run() + return await super().async_run() def _finish(self) -> None: if self.lock_acquired: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 0d017dda64f..9468eb6bf49 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1478,10 +1478,14 @@ def floors(hass: HomeAssistant) -> Iterable[str | None]: def floor_id(hass: HomeAssistant, lookup_value: Any) -> str | None: - """Get the floor ID from a floor name.""" + """Get the floor ID from a floor or area name, alias, device id, or entity id.""" floor_registry = fr.async_get(hass) - if floor := floor_registry.async_get_floor_by_name(str(lookup_value)): + lookup_str = str(lookup_value) + if floor := floor_registry.async_get_floor_by_name(lookup_str): return floor.floor_id + floors_list = floor_registry.async_get_floors_by_alias(lookup_str) + if floors_list: + return floors_list[0].floor_id if aid := area_id(hass, lookup_value): area_reg = area_registry.async_get(hass) @@ -1541,10 +1545,14 @@ def areas(hass: HomeAssistant) -> Iterable[str | None]: def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: - """Get the area ID from an area name, device id, or entity id.""" + """Get the area ID from an area name, alias, device id, or entity id.""" area_reg = area_registry.async_get(hass) - if area := area_reg.async_get_area_by_name(str(lookup_value)): + lookup_str = str(lookup_value) + if area := area_reg.async_get_area_by_name(lookup_str): return area.id + areas_list = area_reg.async_get_areas_by_alias(lookup_str) + if areas_list: + return areas_list[0].id ent_reg = entity_registry.async_get(hass) dev_reg = device_registry.async_get(hass) @@ -2785,6 +2793,50 @@ def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]: return flattened +def intersect(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return the common elements between two lists.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"intersect expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"intersect expected a list, got {type(other).__name__}") + + return list(set(value) & set(other)) + + +def difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return elements in first list that are not in second list.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"difference expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"difference expected a list, got {type(other).__name__}") + + return list(set(value) - set(other)) + + +def union(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return all unique elements from both lists combined.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"union expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"union expected a list, got {type(other).__name__}") + + return list(set(value) | set(other)) + + +def symmetric_difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return elements that are in either list but not in both.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError( + f"symmetric_difference expected a list, got {type(value).__name__}" + ) + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError( + f"symmetric_difference expected a list, got {type(other).__name__}" + ) + + return list(set(value) ^ set(other)) + + def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: """Combine multiple dictionaries into one.""" if not args: @@ -2996,11 +3048,13 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["bool"] = forgiving_boolean self.globals["combine"] = combine self.globals["cos"] = cosine + self.globals["difference"] = difference self.globals["e"] = math.e self.globals["flatten"] = flatten self.globals["float"] = forgiving_float self.globals["iif"] = iif self.globals["int"] = forgiving_int + self.globals["intersect"] = intersect self.globals["is_number"] = is_number self.globals["log"] = logarithm self.globals["max"] = min_max_from_filter(self.filters["max"], "max") @@ -3020,11 +3074,13 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["sqrt"] = square_root self.globals["statistical_mode"] = statistical_mode self.globals["strptime"] = strptime + self.globals["symmetric_difference"] = symmetric_difference self.globals["tan"] = tangent self.globals["tau"] = math.pi * 2 self.globals["timedelta"] = timedelta self.globals["tuple"] = _to_tuple self.globals["typeof"] = typeof + self.globals["union"] = union self.globals["unpack"] = struct_unpack self.globals["urlencode"] = urlencode self.globals["version"] = version @@ -3049,11 +3105,13 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["combine"] = combine self.filters["contains"] = contains self.filters["cos"] = cosine + self.filters["difference"] = difference self.filters["flatten"] = flatten self.filters["float"] = forgiving_float_filter self.filters["from_json"] = from_json self.filters["iif"] = iif self.filters["int"] = forgiving_int_filter + self.filters["intersect"] = intersect self.filters["is_defined"] = fail_when_undefined self.filters["is_number"] = is_number self.filters["log"] = logarithm @@ -3078,12 +3136,14 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["slugify"] = slugify self.filters["sqrt"] = square_root self.filters["statistical_mode"] = statistical_mode + self.filters["symmetric_difference"] = symmetric_difference self.filters["tan"] = tangent self.filters["timestamp_custom"] = timestamp_custom self.filters["timestamp_local"] = timestamp_local self.filters["timestamp_utc"] = timestamp_utc self.filters["to_json"] = to_json self.filters["typeof"] = typeof + self.filters["union"] = union self.filters["unpack"] = struct_unpack self.filters["version"] = version diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 20763dc7b30..e904fa4bdaf 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -18,7 +18,7 @@ import pathlib import sys import time from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast, final from awesomeversion import ( AwesomeVersion, @@ -646,6 +646,7 @@ def async_register_preload_platform(hass: HomeAssistant, platform_name: str) -> preload_platforms.append(platform_name) +@final # Final to allow direct checking of the type instead of using isinstance class Integration: """An integration in Home Assistant.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1399c1884ea..af75218bf7e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,14 +6,14 @@ aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.14 +aiohttp==3.11.16 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.4.4 +annotatedyaml==0.4.5 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 atomicwrites-homeassistant==1.4.1 attrs==25.1.0 audioop-lts==0.2.1 @@ -24,32 +24,33 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.5 -bluetooth-data-tools==1.26.1 +bluetooth-data-tools==1.27.0 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.39.6 +dbus-fast==2.43.0 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.32.0 +habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250306.0 -home-assistant-intents==2025.3.5 +home-assistant-frontend==20250404.0 +home-assistant-intents==2025.3.28 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.15 +numpy==2.2.2 +orjson==3.10.16 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.1.0 -propcache==0.3.0 +propcache==0.3.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 @@ -62,18 +63,18 @@ PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 standard-aifc==3.13.0 standard-telnetlib==3.13.0 -typing-extensions>=4.12.2,<5.0 +typing-extensions>=4.13.0,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.8 +uv==0.6.10 voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.18.3 +yarl==1.19.0 zeroconf==0.146.0 # Constrain pycryptodome to avoid vulnerability @@ -87,9 +88,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.67.1 -grpcio-status==1.67.1 -grpcio-reflection==1.67.1 +grpcio==1.71.0 +grpcio-status==1.71.0 +grpcio-reflection==1.71.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -109,7 +110,7 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.8.0 +anyio==4.9.0 h11==0.14.0 httpcore==1.0.7 @@ -129,7 +130,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.6 +pydantic==2.11.2 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 9572136559a..76061b72b73 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -202,28 +202,40 @@ async def _async_process_dependencies( """ setup_futures = hass.data.setdefault(DATA_SETUP, {}) - dependencies_tasks = { - dep: setup_futures.get(dep) - or create_eager_task( - async_setup_component(hass, dep, config), - name=f"setup {dep} as dependency of {integration.domain}", - loop=hass.loop, - ) - for dep in integration.dependencies - if dep not in hass.config.components - } + dependencies_tasks: dict[str, asyncio.Future[bool]] = {} + + for dep in integration.dependencies: + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( + async_setup_component(hass, dep, config), + name=f"setup {dep} as dependency of {integration.domain}", + loop=hass.loop, + ) + dependencies_tasks[dep] = fut - after_dependencies_tasks: dict[str, asyncio.Future[bool]] = {} to_be_loaded = hass.data.get(DATA_SETUP_DONE, {}) + # We don't want to just wait for the futures from `to_be_loaded` here. + # We want to ensure that our after_dependencies are always actually + # scheduled to be set up, as if for whatever reason they had not been, + # we would deadlock waiting for them here. for dep in integration.after_dependencies: - if ( - dep not in dependencies_tasks - and dep in to_be_loaded - and dep not in hass.config.components - ): - after_dependencies_tasks[dep] = to_be_loaded[dep] + if dep not in to_be_loaded or dep in dependencies_tasks: + continue + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( + async_setup_component(hass, dep, config), + name=f"setup {dep} as after dependency of {integration.domain}", + loop=hass.loop, + ) + dependencies_tasks[dep] = fut - if not dependencies_tasks and not after_dependencies_tasks: + if not dependencies_tasks: return [] if dependencies_tasks: @@ -232,17 +244,9 @@ async def _async_process_dependencies( integration.domain, dependencies_tasks.keys(), ) - if after_dependencies_tasks: - _LOGGER.debug( - "Dependency %s will wait for after dependencies %s", - integration.domain, - after_dependencies_tasks.keys(), - ) async with hass.timeout.async_freeze(integration.domain): - results = await asyncio.gather( - *dependencies_tasks.values(), *after_dependencies_tasks.values() - ) + results = await asyncio.gather(*dependencies_tasks.values()) failed = [ domain for idx, domain in enumerate(dependencies_tasks) if not results[idx] diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 29b7db7a011..14190ba008d 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -47,6 +47,7 @@ "access_token": "Access token", "api_key": "API key", "api_token": "API token", + "country": "Country", "device": "Device", "elevation": "Elevation", "email": "Email", @@ -119,22 +120,31 @@ "active": "Active", "charging": "Charging", "closed": "Closed", + "closing": "Closing", "connected": "Connected", "disabled": "Disabled", "discharging": "Discharging", "disconnected": "Disconnected", "enabled": "Enabled", + "high": "High", "home": "Home", "idle": "Idle", "locked": "Locked", + "low": "Low", + "medium": "Medium", "no": "No", + "normal": "Normal", "not_home": "Away", "off": "Off", "on": "On", "open": "Open", + "opening": "Opening", "paused": "Paused", "standby": "Standby", + "stopped": "Stopped", "unlocked": "Unlocked", + "very_high": "Very high", + "very_low": "Very low", "yes": "Yes" }, "time": { diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 1e516742bfe..d5dfab7da6c 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -29,16 +29,22 @@ class HomeAssistantQueueListener(logging.handlers.QueueListener): LOG_COUNTS_RESET_INTERVAL = 300 MAX_LOGS_COUNT = 200 + EXCLUDED_LOG_COUNT_MODULES = [ + "homeassistant.components.automation", + "homeassistant.components.script", + "homeassistant.setup", + "homeassistant.util.logging", + ] + _last_reset: float _log_counts: dict[str, int] - _warned_modules: set[str] def __init__( self, queue: SimpleQueue[logging.Handler], *handlers: logging.Handler ) -> None: """Initialize the handler.""" super().__init__(queue, *handlers) - self._warned_modules = set() + self._module_log_count_skip_flags: dict[str, bool] = {} self._reset_counters(time.time()) @override @@ -53,7 +59,11 @@ class HomeAssistantQueueListener(logging.handlers.QueueListener): self._reset_counters(record.created) module_name = record.name - if module_name == __name__ or module_name in self._warned_modules: + + if skip_flag := self._module_log_count_skip_flags.get(module_name): + return + + if skip_flag is None and self._update_skip_flags(module_name): return self._log_counts[module_name] += 1 @@ -66,13 +76,20 @@ class HomeAssistantQueueListener(logging.handlers.QueueListener): module_name, module_count, ) - self._warned_modules.add(module_name) + self._module_log_count_skip_flags[module_name] = True def _reset_counters(self, time_sec: float) -> None: _LOGGER.debug("Resetting log counters") self._last_reset = time_sec self._log_counts = defaultdict(int) + def _update_skip_flags(self, module_name: str) -> bool: + excluded = any( + module_name.startswith(prefix) for prefix in self.EXCLUDED_LOG_COUNT_MODULES + ) + self._module_log_count_skip_flags[module_name] = excluded + return excluded + class HomeAssistantQueueHandler(logging.handlers.QueueHandler): """Process the log in another thread.""" diff --git a/homeassistant/util/read_only_dict.py b/homeassistant/util/read_only_dict.py index 02befa78f60..3e4710cf220 100644 --- a/homeassistant/util/read_only_dict.py +++ b/homeassistant/util/read_only_dict.py @@ -1,7 +1,7 @@ """Read only dictionary.""" from copy import deepcopy -from typing import Any +from typing import Any, final def _readonly(*args: Any, **kwargs: Any) -> Any: @@ -9,6 +9,7 @@ def _readonly(*args: Any, **kwargs: Any) -> Any: raise RuntimeError("Cannot modify ReadOnlyDict") +@final # Final to allow direct checking of the type instead of using isinstance class ReadOnlyDict[_KT, _VT](dict[_KT, _VT]): """Read only version of dict that is compatible with dict types.""" diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 15993cbae47..055f435503f 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from numbers import Number from typing import TYPE_CHECKING, Final @@ -82,9 +83,21 @@ def _is_valid_unit(unit: str, unit_type: str) -> bool: return False +@dataclass(frozen=True, kw_only=True) class UnitSystem: """A container for units of measure.""" + _name: str + accumulated_precipitation_unit: UnitOfPrecipitationDepth + area_unit: UnitOfArea + length_unit: UnitOfLength + mass_unit: UnitOfMass + pressure_unit: UnitOfPressure + temperature_unit: UnitOfTemperature + volume_unit: UnitOfVolume + wind_speed_unit: UnitOfSpeed + _conversions: dict[tuple[SensorDeviceClass | str | None, str | None], str] + def __init__( self, name: str, @@ -118,16 +131,16 @@ class UnitSystem: if errors: raise ValueError(errors) - self._name = name - self.accumulated_precipitation_unit = accumulated_precipitation - self.area_unit = area - self.length_unit = length - self.mass_unit = mass - self.pressure_unit = pressure - self.temperature_unit = temperature - self.volume_unit = volume - self.wind_speed_unit = wind_speed - self._conversions = conversions + super().__setattr__("_name", name) + super().__setattr__("accumulated_precipitation_unit", accumulated_precipitation) + super().__setattr__("area_unit", area) + super().__setattr__("length_unit", length) + super().__setattr__("mass_unit", mass) + super().__setattr__("pressure_unit", pressure) + super().__setattr__("temperature_unit", temperature) + super().__setattr__("volume_unit", volume) + super().__setattr__("wind_speed_unit", wind_speed) + super().__setattr__("_conversions", conversions) def temperature(self, temperature: float, from_unit: str) -> float: """Convert the given temperature to this unit system.""" diff --git a/mypy.ini b/mypy.ini index 852678677bb..685412e6e98 100644 --- a/mypy.ini +++ b/mypy.ini @@ -945,6 +945,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bosch_alarm.*] +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.braviatv.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3386,6 +3396,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ohme.*] +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.onboarding.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 1bd74791a18..7c35d1d2f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,98 +1,138 @@ [build-system] -requires = ["setuptools==77.0.1"] +requires = ["setuptools==77.0.3"] build-backend = "setuptools.build_meta" [project] -name = "homeassistant" -version = "2025.4.0.dev0" -license = "Apache-2.0" +name = "homeassistant" +version = "2025.5.0.dev0" +license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." -readme = "README.rst" -authors = [ - {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} +readme = "README.rst" +authors = [ + { name = "The Home Assistant Authors", email = "hello@home-assistant.io" }, ] -keywords = ["home", "automation"] +keywords = ["home", "automation"] classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: End Users/Desktop", - "Intended Audience :: Developers", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.13", - "Topic :: Home Automation", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.13", + "Topic :: Home Automation", ] requires-python = ">=3.13.0" -dependencies = [ - "aiodns==3.2.0", - # Integrations may depend on hassio integration without listing it to - # change behavior based on presence of supervisor. Deprecated with #127228 - # Lib can be removed with 2025.11 - "aiohasupervisor==0.3.0", - "aiohttp==3.11.14", - "aiohttp_cors==0.7.0", - "aiohttp-fast-zlib==0.2.3", - "aiohttp-asyncmdnsresolver==0.1.1", - "aiozoneinfo==0.2.3", - "annotatedyaml==0.4.4", - "astral==2.2", - "async-interrupt==1.2.2", - "attrs==25.1.0", - "atomicwrites-homeassistant==1.4.1", - "audioop-lts==0.2.1", - "awesomeversion==24.6.0", - "bcrypt==4.2.0", - "certifi>=2021.5.30", - "ciso8601==2.3.2", - "cronsim==2.6", - "fnv-hash-fast==1.4.0", - # hass-nabucasa is imported by helpers which don't depend on the cloud - # integration - "hass-nabucasa==0.94.0", - # When bumping httpx, please check the version pins of - # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.28.1", - "home-assistant-bluetooth==1.13.1", - "ifaddr==0.2.0", - "Jinja2==3.1.6", - "lru-dict==1.3.0", - "PyJWT==2.10.1", - # PyJWT has loose dependency. We want the latest one. - "cryptography==44.0.1", - "Pillow==11.1.0", - "propcache==0.3.0", - "pyOpenSSL==25.0.0", - "orjson==3.10.15", - "packaging>=23.1", - "psutil-home-assistant==0.0.1", - "python-slugify==8.0.4", - "PyYAML==6.0.2", - "requests==2.32.3", - "securetar==2025.2.1", - "SQLAlchemy==2.0.39", - "standard-aifc==3.13.0", - "standard-telnetlib==3.13.0", - "typing-extensions>=4.12.2,<5.0", - "ulid-transform==1.4.0", - # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 - # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 - # https://github.com/home-assistant/core/issues/97248 - "urllib3>=1.26.5,<2", - "uv==0.6.8", - "voluptuous==0.15.2", - "voluptuous-serialize==2.6.0", - "voluptuous-openapi==0.0.6", - "yarl==1.18.3", - "webrtc-models==0.3.0", - "zeroconf==0.146.0" +dependencies = [ + "aiodns==3.2.0", + # Integrations may depend on hassio integration without listing it to + # change behavior based on presence of supervisor. Deprecated with #127228 + # Lib can be removed with 2025.11 + "aiohasupervisor==0.3.0", + "aiohttp==3.11.16", + "aiohttp_cors==0.7.0", + "aiohttp-fast-zlib==0.2.3", + "aiohttp-asyncmdnsresolver==0.1.1", + "aiozoneinfo==0.2.3", + "annotatedyaml==0.4.5", + "astral==2.2", + "async-interrupt==1.2.2", + "attrs==25.1.0", + "atomicwrites-homeassistant==1.4.1", + "audioop-lts==0.2.1", + "awesomeversion==24.6.0", + "bcrypt==4.2.0", + "certifi>=2021.5.30", + "ciso8601==2.3.2", + "cronsim==2.6", + "fnv-hash-fast==1.4.0", + # ha-ffmpeg is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->tts->ffmpeg. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "ha-ffmpeg==3.2.2", + # hass-nabucasa is imported by helpers which don't depend on the cloud + # integration + "hass-nabucasa==0.94.0", + # hassil is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "hassil==2.2.3", + # When bumping httpx, please check the version pins of + # httpcore, anyio, and h11 in gen_requirements_all + "httpx==0.28.1", + "home-assistant-bluetooth==1.13.1", + # home_assistant_intents is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->conversation->home_assistant_intents. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "home-assistant-intents==2025.3.28", + "ifaddr==0.2.0", + "Jinja2==3.1.6", + "lru-dict==1.3.0", + # mutagen is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->tts->mutagen. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "mutagen==1.47.0", + # numpy is indirectly imported from onboarding via the import chain + # onboarding->cloud->alexa->camera->stream->numpy. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "numpy==2.2.2", + "PyJWT==2.10.1", + # PyJWT has loose dependency. We want the latest one. + "cryptography==44.0.1", + "Pillow==11.1.0", + "propcache==0.3.1", + "pyOpenSSL==25.0.0", + "orjson==3.10.16", + "packaging>=23.1", + "psutil-home-assistant==0.0.1", + # pymicro_vad is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->pymicro_vad. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "pymicro-vad==1.0.1", + # pyspeex-noise is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->pyspeex_noise. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "pyspeex-noise==1.0.2", + "python-slugify==8.0.4", + # PyTurboJPEG is indirectly imported from onboarding via the import chain + # onboarding->cloud->camera->pyturbojpeg. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "PyTurboJPEG==1.7.5", + "PyYAML==6.0.2", + "requests==2.32.3", + "securetar==2025.2.1", + "SQLAlchemy==2.0.40", + "standard-aifc==3.13.0", + "standard-telnetlib==3.13.0", + "typing-extensions>=4.13.0,<5.0", + "ulid-transform==1.4.0", + # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 + # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 + # https://github.com/home-assistant/core/issues/97248 + "urllib3>=1.26.5,<2", + "uv==0.6.10", + "voluptuous==0.15.2", + "voluptuous-serialize==2.6.0", + "voluptuous-openapi==0.0.6", + "yarl==1.19.0", + "webrtc-models==0.3.0", + "zeroconf==0.146.0", ] [project.urls] -"Homepage" = "https://www.home-assistant.io/" +"Homepage" = "https://www.home-assistant.io/" "Source Code" = "https://github.com/home-assistant/core" "Bug Reports" = "https://github.com/home-assistant/core/issues" -"Docs: Dev" = "https://developers.home-assistant.io/" -"Discord" = "https://www.home-assistant.io/join-chat/" -"Forum" = "https://community.home-assistant.io/" +"Docs: Dev" = "https://developers.home-assistant.io/" +"Discord" = "https://www.home-assistant.io/join-chat/" +"Forum" = "https://community.home-assistant.io/" [project.scripts] hass = "homeassistant.__main__:main" @@ -119,30 +159,28 @@ init-hook = """\ ) \ """ load-plugins = [ - "pylint.extensions.code_style", - "pylint.extensions.typing", - "hass_decorator", - "hass_enforce_class_module", - "hass_enforce_sorted_platforms", - "hass_enforce_super_call", - "hass_enforce_type_hints", - "hass_inheritance", - "hass_imports", - "hass_logger", - "pylint_per_file_ignores", + "pylint.extensions.code_style", + "pylint.extensions.typing", + "hass_decorator", + "hass_enforce_class_module", + "hass_enforce_sorted_platforms", + "hass_enforce_super_call", + "hass_enforce_type_hints", + "hass_inheritance", + "hass_imports", + "hass_logger", + "pylint_per_file_ignores", ] persistent = false extension-pkg-allow-list = [ - "av.audio.stream", - "av.logging", - "av.stream", - "ciso8601", - "orjson", - "cv2", -] -fail-on = [ - "I", + "av.audio.stream", + "av.logging", + "av.stream", + "ciso8601", + "orjson", + "cv2", ] +fail-on = ["I"] [tool.pylint.BASIC] class-const-naming-style = "any" @@ -167,257 +205,257 @@ class-const-naming-style = "any" # consider-using-namedtuple-or-dataclass - too opinionated # consider-using-assignment-expr - decision to use := better left to devs disable = [ - "format", - "abstract-method", - "cyclic-import", - "duplicate-code", - "inconsistent-return-statements", - "locally-disabled", - "not-context-manager", - "too-few-public-methods", - "too-many-ancestors", - "too-many-arguments", - "too-many-instance-attributes", - "too-many-lines", - "too-many-locals", - "too-many-public-methods", - "too-many-boolean-expressions", - "too-many-positional-arguments", - "wrong-import-order", - "consider-using-namedtuple-or-dataclass", - "consider-using-assignment-expr", - "possibly-used-before-assignment", + "format", + "abstract-method", + "cyclic-import", + "duplicate-code", + "inconsistent-return-statements", + "locally-disabled", + "not-context-manager", + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-public-methods", + "too-many-boolean-expressions", + "too-many-positional-arguments", + "wrong-import-order", + "consider-using-namedtuple-or-dataclass", + "consider-using-assignment-expr", + "possibly-used-before-assignment", - # Handled by ruff - # Ref: - "await-outside-async", # PLE1142 - "bad-str-strip-call", # PLE1310 - "bad-string-format-type", # PLE1307 - "bidirectional-unicode", # PLE2502 - "continue-in-finally", # PLE0116 - "duplicate-bases", # PLE0241 - "misplaced-bare-raise", # PLE0704 - "format-needs-mapping", # F502 - "function-redefined", # F811 - # Needed because ruff does not understand type of __all__ generated by a function - # "invalid-all-format", # PLE0605 - "invalid-all-object", # PLE0604 - "invalid-character-backspace", # PLE2510 - "invalid-character-esc", # PLE2513 - "invalid-character-nul", # PLE2514 - "invalid-character-sub", # PLE2512 - "invalid-character-zero-width-space", # PLE2515 - "logging-too-few-args", # PLE1206 - "logging-too-many-args", # PLE1205 - "missing-format-string-key", # F524 - "mixed-format-string", # F506 - "no-method-argument", # N805 - "no-self-argument", # N805 - "nonexistent-operator", # B002 - "nonlocal-without-binding", # PLE0117 - "not-in-loop", # F701, F702 - "notimplemented-raised", # F901 - "return-in-init", # PLE0101 - "return-outside-function", # F706 - "syntax-error", # E999 - "too-few-format-args", # F524 - "too-many-format-args", # F522 - "too-many-star-expressions", # F622 - "truncated-format-string", # F501 - "undefined-all-variable", # F822 - "undefined-variable", # F821 - "used-prior-global-declaration", # PLE0118 - "yield-inside-async-function", # PLE1700 - "yield-outside-function", # F704 - "anomalous-backslash-in-string", # W605 - "assert-on-string-literal", # PLW0129 - "assert-on-tuple", # F631 - "bad-format-string", # W1302, F - "bad-format-string-key", # W1300, F - "bare-except", # E722 - "binary-op-exception", # PLW0711 - "cell-var-from-loop", # B023 - # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work - "duplicate-except", # B014 - "duplicate-key", # F601 - "duplicate-string-formatting-argument", # F - "duplicate-value", # F - "eval-used", # S307 - "exec-used", # S102 - "expression-not-assigned", # B018 - "f-string-without-interpolation", # F541 - "forgotten-debug-statement", # T100 - "format-string-without-interpolation", # F - # "global-statement", # PLW0603, ruff catches new occurrences, needs more work - "global-variable-not-assigned", # PLW0602 - "implicit-str-concat", # ISC001 - "import-self", # PLW0406 - "inconsistent-quotes", # Q000 - "invalid-envvar-default", # PLW1508 - "keyword-arg-before-vararg", # B026 - "logging-format-interpolation", # G - "logging-fstring-interpolation", # G - "logging-not-lazy", # G - "misplaced-future", # F404 - "named-expr-without-context", # PLW0131 - "nested-min-max", # PLW3301 - "pointless-statement", # B018 - "raise-missing-from", # B904 - "redefined-builtin", # A001 - "try-except-raise", # TRY302 - "unused-argument", # ARG001, we don't use it - "unused-format-string-argument", #F507 - "unused-format-string-key", # F504 - "unused-import", # F401 - "unused-variable", # F841 - "useless-else-on-loop", # PLW0120 - "wildcard-import", # F403 - "bad-classmethod-argument", # N804 - "consider-iterating-dictionary", # SIM118 - "empty-docstring", # D419 - "invalid-name", # N815 - "line-too-long", # E501, disabled globally - "missing-class-docstring", # D101 - "missing-final-newline", # W292 - "missing-function-docstring", # D103 - "missing-module-docstring", # D100 - "multiple-imports", #E401 - "singleton-comparison", # E711, E712 - "subprocess-run-check", # PLW1510 - "superfluous-parens", # UP034 - "ungrouped-imports", # I001 - "unidiomatic-typecheck", # E721 - "unnecessary-direct-lambda-call", # PLC3002 - "unnecessary-lambda-assignment", # PLC3001 - "unnecessary-pass", # PIE790 - "unneeded-not", # SIM208 - "useless-import-alias", # PLC0414 - "wrong-import-order", # I001 - "wrong-import-position", # E402 - "comparison-of-constants", # PLR0133 - "comparison-with-itself", # PLR0124 - "consider-alternative-union-syntax", # UP007 - "consider-merging-isinstance", # PLR1701 - "consider-using-alias", # UP006 - "consider-using-dict-comprehension", # C402 - "consider-using-generator", # C417 - "consider-using-get", # SIM401 - "consider-using-set-comprehension", # C401 - "consider-using-sys-exit", # PLR1722 - "consider-using-ternary", # SIM108 - "literal-comparison", # F632 - "property-with-parameters", # PLR0206 - "super-with-arguments", # UP008 - "too-many-branches", # PLR0912 - "too-many-return-statements", # PLR0911 - "too-many-statements", # PLR0915 - "trailing-comma-tuple", # COM818 - "unnecessary-comprehension", # C416 - "use-a-generator", # C417 - "use-dict-literal", # C406 - "use-list-literal", # C405 - "useless-object-inheritance", # UP004 - "useless-return", # PLR1711 - "no-else-break", # RET508 - "no-else-continue", # RET507 - "no-else-raise", # RET506 - "no-else-return", # RET505 - "broad-except", # BLE001 - "protected-access", # SLF001 - "broad-exception-raised", # TRY002 - "consider-using-f-string", # PLC0209 - # "no-self-use", # PLR6301 # Optional plugin, not enabled + # Handled by ruff + # Ref: + "await-outside-async", # PLE1142 + "bad-str-strip-call", # PLE1310 + "bad-string-format-type", # PLE1307 + "bidirectional-unicode", # PLE2502 + "continue-in-finally", # PLE0116 + "duplicate-bases", # PLE0241 + "misplaced-bare-raise", # PLE0704 + "format-needs-mapping", # F502 + "function-redefined", # F811 + # Needed because ruff does not understand type of __all__ generated by a function + # "invalid-all-format", # PLE0605 + "invalid-all-object", # PLE0604 + "invalid-character-backspace", # PLE2510 + "invalid-character-esc", # PLE2513 + "invalid-character-nul", # PLE2514 + "invalid-character-sub", # PLE2512 + "invalid-character-zero-width-space", # PLE2515 + "logging-too-few-args", # PLE1206 + "logging-too-many-args", # PLE1205 + "missing-format-string-key", # F524 + "mixed-format-string", # F506 + "no-method-argument", # N805 + "no-self-argument", # N805 + "nonexistent-operator", # B002 + "nonlocal-without-binding", # PLE0117 + "not-in-loop", # F701, F702 + "notimplemented-raised", # F901 + "return-in-init", # PLE0101 + "return-outside-function", # F706 + "syntax-error", # E999 + "too-few-format-args", # F524 + "too-many-format-args", # F522 + "too-many-star-expressions", # F622 + "truncated-format-string", # F501 + "undefined-all-variable", # F822 + "undefined-variable", # F821 + "used-prior-global-declaration", # PLE0118 + "yield-inside-async-function", # PLE1700 + "yield-outside-function", # F704 + "anomalous-backslash-in-string", # W605 + "assert-on-string-literal", # PLW0129 + "assert-on-tuple", # F631 + "bad-format-string", # W1302, F + "bad-format-string-key", # W1300, F + "bare-except", # E722 + "binary-op-exception", # PLW0711 + "cell-var-from-loop", # B023 + # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work + "duplicate-except", # B014 + "duplicate-key", # F601 + "duplicate-string-formatting-argument", # F + "duplicate-value", # F + "eval-used", # S307 + "exec-used", # S102 + "expression-not-assigned", # B018 + "f-string-without-interpolation", # F541 + "forgotten-debug-statement", # T100 + "format-string-without-interpolation", # F + # "global-statement", # PLW0603, ruff catches new occurrences, needs more work + "global-variable-not-assigned", # PLW0602 + "implicit-str-concat", # ISC001 + "import-self", # PLW0406 + "inconsistent-quotes", # Q000 + "invalid-envvar-default", # PLW1508 + "keyword-arg-before-vararg", # B026 + "logging-format-interpolation", # G + "logging-fstring-interpolation", # G + "logging-not-lazy", # G + "misplaced-future", # F404 + "named-expr-without-context", # PLW0131 + "nested-min-max", # PLW3301 + "pointless-statement", # B018 + "raise-missing-from", # B904 + "redefined-builtin", # A001 + "try-except-raise", # TRY302 + "unused-argument", # ARG001, we don't use it + "unused-format-string-argument", #F507 + "unused-format-string-key", # F504 + "unused-import", # F401 + "unused-variable", # F841 + "useless-else-on-loop", # PLW0120 + "wildcard-import", # F403 + "bad-classmethod-argument", # N804 + "consider-iterating-dictionary", # SIM118 + "empty-docstring", # D419 + "invalid-name", # N815 + "line-too-long", # E501, disabled globally + "missing-class-docstring", # D101 + "missing-final-newline", # W292 + "missing-function-docstring", # D103 + "missing-module-docstring", # D100 + "multiple-imports", #E401 + "singleton-comparison", # E711, E712 + "subprocess-run-check", # PLW1510 + "superfluous-parens", # UP034 + "ungrouped-imports", # I001 + "unidiomatic-typecheck", # E721 + "unnecessary-direct-lambda-call", # PLC3002 + "unnecessary-lambda-assignment", # PLC3001 + "unnecessary-pass", # PIE790 + "unneeded-not", # SIM208 + "useless-import-alias", # PLC0414 + "wrong-import-order", # I001 + "wrong-import-position", # E402 + "comparison-of-constants", # PLR0133 + "comparison-with-itself", # PLR0124 + "consider-alternative-union-syntax", # UP007 + "consider-merging-isinstance", # PLR1701 + "consider-using-alias", # UP006 + "consider-using-dict-comprehension", # C402 + "consider-using-generator", # C417 + "consider-using-get", # SIM401 + "consider-using-set-comprehension", # C401 + "consider-using-sys-exit", # PLR1722 + "consider-using-ternary", # SIM108 + "literal-comparison", # F632 + "property-with-parameters", # PLR0206 + "super-with-arguments", # UP008 + "too-many-branches", # PLR0912 + "too-many-return-statements", # PLR0911 + "too-many-statements", # PLR0915 + "trailing-comma-tuple", # COM818 + "unnecessary-comprehension", # C416 + "use-a-generator", # C417 + "use-dict-literal", # C406 + "use-list-literal", # C405 + "useless-object-inheritance", # UP004 + "useless-return", # PLR1711 + "no-else-break", # RET508 + "no-else-continue", # RET507 + "no-else-raise", # RET506 + "no-else-return", # RET505 + "broad-except", # BLE001 + "protected-access", # SLF001 + "broad-exception-raised", # TRY002 + "consider-using-f-string", # PLC0209 + # "no-self-use", # PLR6301 # Optional plugin, not enabled - # Handled by mypy - # Ref: - "abstract-class-instantiated", - "arguments-differ", - "assigning-non-slot", - "assignment-from-no-return", - "assignment-from-none", - "bad-exception-cause", - "bad-format-character", - "bad-reversed-sequence", - "bad-super-call", - "bad-thread-instantiation", - "catching-non-exception", - "comparison-with-callable", - "deprecated-class", - "dict-iter-missing-items", - "format-combined-specification", - "global-variable-undefined", - "import-error", - "inconsistent-mro", - "inherit-non-class", - "init-is-generator", - "invalid-class-object", - "invalid-enum-extension", - "invalid-envvar-value", - "invalid-format-returned", - "invalid-hash-returned", - "invalid-metaclass", - "invalid-overridden-method", - "invalid-repr-returned", - "invalid-sequence-index", - "invalid-slice-index", - "invalid-slots-object", - "invalid-slots", - "invalid-star-assignment-target", - "invalid-str-returned", - "invalid-unary-operand-type", - "invalid-unicode-codec", - "isinstance-second-argument-not-valid-type", - "method-hidden", - "misplaced-format-function", - "missing-format-argument-key", - "missing-format-attribute", - "missing-kwoa", - "no-member", - "no-value-for-parameter", - "non-iterator-returned", - "non-str-assignment-to-dunder-name", - "nonlocal-and-global", - "not-a-mapping", - "not-an-iterable", - "not-async-context-manager", - "not-callable", - "not-context-manager", - "overridden-final-method", - "raising-bad-type", - "raising-non-exception", - "redundant-keyword-arg", - "relative-beyond-top-level", - "self-cls-assignment", - "signature-differs", - "star-needs-assignment-target", - "subclassed-final-class", - "super-without-brackets", - "too-many-function-args", - "typevar-double-variance", - "typevar-name-mismatch", - "unbalanced-dict-unpacking", - "unbalanced-tuple-unpacking", - "unexpected-keyword-arg", - "unhashable-member", - "unpacking-non-sequence", - "unsubscriptable-object", - "unsupported-assignment-operation", - "unsupported-binary-operation", - "unsupported-delete-operation", - "unsupported-membership-test", - "used-before-assignment", - "using-final-decorator-in-unsupported-version", - "wrong-exception-operation", + # Handled by mypy + # Ref: + "abstract-class-instantiated", + "arguments-differ", + "assigning-non-slot", + "assignment-from-no-return", + "assignment-from-none", + "bad-exception-cause", + "bad-format-character", + "bad-reversed-sequence", + "bad-super-call", + "bad-thread-instantiation", + "catching-non-exception", + "comparison-with-callable", + "deprecated-class", + "dict-iter-missing-items", + "format-combined-specification", + "global-variable-undefined", + "import-error", + "inconsistent-mro", + "inherit-non-class", + "init-is-generator", + "invalid-class-object", + "invalid-enum-extension", + "invalid-envvar-value", + "invalid-format-returned", + "invalid-hash-returned", + "invalid-metaclass", + "invalid-overridden-method", + "invalid-repr-returned", + "invalid-sequence-index", + "invalid-slice-index", + "invalid-slots-object", + "invalid-slots", + "invalid-star-assignment-target", + "invalid-str-returned", + "invalid-unary-operand-type", + "invalid-unicode-codec", + "isinstance-second-argument-not-valid-type", + "method-hidden", + "misplaced-format-function", + "missing-format-argument-key", + "missing-format-attribute", + "missing-kwoa", + "no-member", + "no-value-for-parameter", + "non-iterator-returned", + "non-str-assignment-to-dunder-name", + "nonlocal-and-global", + "not-a-mapping", + "not-an-iterable", + "not-async-context-manager", + "not-callable", + "not-context-manager", + "overridden-final-method", + "raising-bad-type", + "raising-non-exception", + "redundant-keyword-arg", + "relative-beyond-top-level", + "self-cls-assignment", + "signature-differs", + "star-needs-assignment-target", + "subclassed-final-class", + "super-without-brackets", + "too-many-function-args", + "typevar-double-variance", + "typevar-name-mismatch", + "unbalanced-dict-unpacking", + "unbalanced-tuple-unpacking", + "unexpected-keyword-arg", + "unhashable-member", + "unpacking-non-sequence", + "unsubscriptable-object", + "unsupported-assignment-operation", + "unsupported-binary-operation", + "unsupported-delete-operation", + "unsupported-membership-test", + "used-before-assignment", + "using-final-decorator-in-unsupported-version", + "wrong-exception-operation", ] enable = [ - #"useless-suppression", # temporarily every now and then to clean them up - "use-symbolic-message-instead", + #"useless-suppression", # temporarily every now and then to clean them up + "use-symbolic-message-instead", ] per-file-ignores = [ - # redefined-outer-name: Tests reference fixtures in the test function - # use-implicit-booleaness-not-comparison: Tests need to validate that a list - # or a dict is returned - "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", + # redefined-outer-name: Tests reference fixtures in the test function + # use-implicit-booleaness-not-comparison: Tests need to validate that a list + # or a dict is returned + "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", ] [tool.pylint.REPORTS] @@ -425,7 +463,7 @@ score = false [tool.pylint.TYPECHECK] ignored-classes = [ - "_CountingAttr", # for attrs + "_CountingAttr", # for attrs ] mixin-class-rgx = ".*[Mm]ix[Ii]n" @@ -434,9 +472,9 @@ expected-line-ending-format = "LF" [tool.pylint.EXCEPTIONS] overgeneral-exceptions = [ - "builtins.BaseException", - "builtins.Exception", - # "homeassistant.exceptions.HomeAssistantError", # too many issues + "builtins.BaseException", + "builtins.Exception", + # "homeassistant.exceptions.HomeAssistantError", # too many issues ] [tool.pylint.TYPING] @@ -446,241 +484,234 @@ runtime-typing = false max-line-length-suggestions = 72 [tool.pytest.ini_options] -testpaths = [ - "tests", -] -norecursedirs = [ - ".git", - "testing_config", -] +testpaths = ["tests"] +norecursedirs = [".git", "testing_config"] log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [ - "error::sqlalchemy.exc.SAWarning", + "error::sqlalchemy.exc.SAWarning", - # -- HomeAssistant - aiohttp - # Overwrite web.Application to pass a custom default argument to _make_request - "ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning", - # Hass wraps `ClientSession.close` to emit a warning if the session is closed accidentally - "ignore:Setting custom ClientSession.close attribute is discouraged:DeprecationWarning:homeassistant.helpers.aiohttp_client", - # Modify app state for testing - "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", + # -- HomeAssistant - aiohttp + # Overwrite web.Application to pass a custom default argument to _make_request + "ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning", + # Hass wraps `ClientSession.close` to emit a warning if the session is closed accidentally + "ignore:Setting custom ClientSession.close attribute is discouraged:DeprecationWarning:homeassistant.helpers.aiohttp_client", + # Modify app state for testing + "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", # -- Tests # Ignore custom pytest marks "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", - # https://github.com/rokam/sunweg/blob/3.1.0/sunweg/plant.py#L96 - v3.1.0 - 2024-10-02 - "ignore:The '(kwh_per_kwp|performance_rate)' property is deprecated and will return 0:DeprecationWarning:tests.components.sunweg.test_init", - # -- design choice 3rd party - # https://github.com/gwww/elkm1/blob/2.2.10/elkm1_lib/util.py#L8-L19 - "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", - # https://github.com/allenporter/ical/pull/215 - # https://github.com/allenporter/ical/blob/8.2.0/ical/util.py#L21-L23 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", - # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 - "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", + # -- design choice 3rd party + # https://github.com/gwww/elkm1/blob/2.2.10/elkm1_lib/util.py#L8-L19 + "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", + # https://github.com/allenporter/ical/pull/215 + # https://github.com/allenporter/ical/blob/8.2.0/ical/util.py#L21-L23 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", + # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 + "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", - # -- Setuptools DeprecationWarnings - # https://github.com/googleapis/google-cloud-python/issues/11184 - # https://github.com/zopefoundation/meta/issues/194 - # https://github.com/Azure/azure-sdk-for-python - "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", + # -- Setuptools DeprecationWarnings + # https://github.com/googleapis/google-cloud-python/issues/11184 + # https://github.com/zopefoundation/meta/issues/194 + # https://github.com/Azure/azure-sdk-for-python + "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", - # -- tracked upstream / open PRs - # - pyOpenSSL v24.2.1 - # https://github.com/certbot/certbot/issues/9828 - v2.11.0 - # https://github.com/certbot/certbot/issues/9992 - "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", - # - other - # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 - # https://github.com/foxel/python_ndms2_client/pull/8 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", + # -- tracked upstream / open PRs + # - pyOpenSSL v24.2.1 + # https://github.com/certbot/certbot/issues/9828 - v2.11.0 + # https://github.com/certbot/certbot/issues/9992 + "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", + "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", + "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", + # - other + # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 + # https://github.com/foxel/python_ndms2_client/pull/8 + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", - # -- fixed, waiting for release / update - # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", - # https://bugs.launchpad.net/beautifulsoup/+bug/2076897 - >4.12.3 - "ignore:The 'strip_cdata' option of HTMLParser\\(\\) has never done anything and will eventually be removed:DeprecationWarning:bs4.builder._lxml", - # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 - "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", - # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", - # https://github.com/fwestenberg/devialet/pull/6 - >1.4.5 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api", - # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", - # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0 - # https://github.com/influxdata/influxdb-client-python/pull/652 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", - # https://github.com/majuss/lupupy/pull/15 - >0.3.2 - "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", - # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", - # https://github.com/eclipse/paho.mqtt.python/issues/653 - >=2.0.0 - # https://github.com/eclipse/paho.mqtt.python/pull/665 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", - # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 - "ignore::DeprecationWarning:holidays", - # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", - # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 - "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", + # -- fixed, waiting for release / update + # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", + # https://bugs.launchpad.net/beautifulsoup/+bug/2076897 - >4.12.3 + "ignore:The 'strip_cdata' option of HTMLParser\\(\\) has never done anything and will eventually be removed:DeprecationWarning:bs4.builder._lxml", + # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 + "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", + # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", + # https://github.com/fwestenberg/devialet/pull/6 - >1.4.5 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api", + # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", + # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0 + # https://github.com/influxdata/influxdb-client-python/pull/652 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", + # https://github.com/majuss/lupupy/pull/15 - >0.3.2 + "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", + # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", + # https://github.com/eclipse/paho.mqtt.python/issues/653 - >=2.0.0 + # https://github.com/eclipse/paho.mqtt.python/pull/665 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", + # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 + "ignore::DeprecationWarning:holidays", + # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", + # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 + "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", - # -- fixed for Python 3.13 - # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:wyoming.audio", + # -- fixed for Python 3.13 + # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:wyoming.audio", - # -- other - # Locale changes might take some time to resolve upstream - # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08 - "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", - # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", - # https://github.com/lidatong/dataclasses-json/issues/328 - # https://github.com/lidatong/dataclasses-json/pull/351 - "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm", - # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 - # https://github.com/martonperei/emulated_roku - "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", - # https://github.com/w1ll1am23/pyeconet/blob/v0.1.23/src/pyeconet/api.py#L38 - v0.1.23 - 2024-10-08 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", - # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 - "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", - # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", - # https://github.com/lextudio/pysnmp/blob/v7.1.10/pysnmp/smi/compiler.py#L23-L31 - v7.1.10 - 2024-11-04 - "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", - "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysmi.reader.url", # wrong stacklevel - # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 - "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", - # Wrong stacklevel - # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 fixed in >4.12.3 - "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:html.parser", - # New in aiohttp - v3.9.0 - "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", - # - SyntaxWarnings - # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 - "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", - # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24 - # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789 - "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera", - # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 - # https://github.com/koolsb/pyblackbird/pull/9 -> closed - "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", - # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", - # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 - # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42 - "ignore:invalid escape sequence:SyntaxWarning:.*sanix", - # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 - "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty - # - pkg_resources - # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", - # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", - # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", - # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", - # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", + # -- other + # Locale changes might take some time to resolve upstream + # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08 + "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", + # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", + # https://github.com/lidatong/dataclasses-json/issues/328 + # https://github.com/lidatong/dataclasses-json/pull/351 + "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm", + # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 + # https://github.com/martonperei/emulated_roku + "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", + # https://github.com/w1ll1am23/pyeconet/blob/v0.1.23/src/pyeconet/api.py#L38 - v0.1.23 - 2024-10-08 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", + # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 + "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", + # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", + # https://github.com/lextudio/pysnmp/blob/v7.1.10/pysnmp/smi/compiler.py#L23-L31 - v7.1.10 - 2024-11-04 + "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", + "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysmi.reader.url", # wrong stacklevel + # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 + "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", + # Wrong stacklevel + # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 fixed in >4.12.3 + "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:html.parser", + # New in aiohttp - v3.9.0 + "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", + # - SyntaxWarnings + # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 + "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", + # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24 + # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789 + "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera", + # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 + # https://github.com/koolsb/pyblackbird/pull/9 -> closed + "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", + # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 + "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", + # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 + # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42 + "ignore:invalid escape sequence:SyntaxWarning:.*sanix", + # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 + "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty + # - pkg_resources + # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", + # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", + # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", + # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", + # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", - # -- Python 3.13 - # HomeAssistant - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api", - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.hddtemp.sensor", - # https://pypi.org/project/nextcord/ - v2.6.0 - 2023-09-23 - # https://github.com/nextcord/nextcord/issues/1174 - # https://github.com/nextcord/nextcord/blob/v2.6.1/nextcord/player.py#L5 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nextcord.player", - # https://pypi.org/project/SpeechRecognition/ - v3.11.0 - 2024-05-05 - # https://github.com/Uberi/speech_recognition/blob/3.11.0/speech_recognition/__init__.py#L7 - "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", - # https://pypi.org/project/voip-utils/ - v0.2.0 - 2024-09-06 - # https://github.com/home-assistant-libs/voip-utils/blob/0.2.0/voip_utils/rtp_audio.py#L3 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio", + # -- Python 3.13 + # HomeAssistant + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api", + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.hddtemp.sensor", + # https://pypi.org/project/nextcord/ - v2.6.0 - 2023-09-23 + # https://github.com/nextcord/nextcord/issues/1174 + # https://github.com/nextcord/nextcord/blob/v2.6.1/nextcord/player.py#L5 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nextcord.player", + # https://pypi.org/project/SpeechRecognition/ - v3.11.0 - 2024-05-05 + # https://github.com/Uberi/speech_recognition/blob/3.11.0/speech_recognition/__init__.py#L7 + "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", + # https://pypi.org/project/voip-utils/ - v0.2.0 - 2024-09-06 + # https://github.com/home-assistant-libs/voip-utils/blob/0.2.0/voip_utils/rtp_audio.py#L3 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio", - # -- Python 3.13 - unmaintained projects, last release about 2+ years - # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pydub.utils", - # https://github.com/heathbar/plum-lightpad-python/issues/7 - v0.0.11 - 2018-10-16 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:plumlightpad.lightpad", - # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i", + # -- Python 3.13 - unmaintained projects, last release about 2+ years + # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pydub.utils", + # https://github.com/heathbar/plum-lightpad-python/issues/7 - v0.0.11 - 2018-10-16 + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:plumlightpad.lightpad", + # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 + # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2 + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i", - # -- New in Python 3.13 - # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 - # https://github.com/kurtmckee/feedparser/issues/481 - "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html", - # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib - "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", + # -- New in Python 3.13 + # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 + # https://github.com/kurtmckee/feedparser/issues/481 + "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html", + # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib + "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", - # -- unmaintained projects, last release about 2+ years - # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", - # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", - # https://pypi.org/project/alarmdecoder/ - v1.13.11 - 2021-06-01 - "ignore:invalid escape sequence:SyntaxWarning:.*alarmdecoder", - # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", - # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", - # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", - # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", - # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` - # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 - # https://github.com/vaidik/commentjson/issues/51 - # https://github.com/vaidik/commentjson/pull/52 - # Fixed upstream, commentjson depends on old version and seems to be unmaintained - "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", - # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", - # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", - # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 - "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", - # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 - "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", - # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight", - # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 - "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)", - # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05 - "ignore:invalid escape sequence:SyntaxWarning:.*ppadb", - # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", - # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", - # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", - # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 - "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", - # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 - "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", - # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants", - # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 - "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", + # -- unmaintained projects, last release about 2+ years + # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", + # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", + # https://pypi.org/project/alarmdecoder/ - v1.13.11 - 2021-06-01 + "ignore:invalid escape sequence:SyntaxWarning:.*alarmdecoder", + # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", + # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", + # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", + # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` + # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 + # https://github.com/vaidik/commentjson/issues/51 + # https://github.com/vaidik/commentjson/pull/52 + # Fixed upstream, commentjson depends on old version and seems to be unmaintained + "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", + # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", + # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", + # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 + "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", + # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 + "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", + # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight", + # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 + "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", + "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)", + # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05 + "ignore:invalid escape sequence:SyntaxWarning:.*ppadb", + # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 + "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", + # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 + "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", + # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", + # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 + "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", + # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 + "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", + # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants", + # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 + "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", ] [tool.coverage.run] @@ -688,16 +719,16 @@ source = ["homeassistant"] [tool.coverage.report] exclude_lines = [ - # Have to re-enable the standard pragma - "pragma: no cover", - # Don't complain about missing debug-only code: - "def __repr__", - # Don't complain if tests don't hit defensive assertion code: - "raise AssertionError", - "raise NotImplementedError", - # TYPE_CHECKING and @overload blocks are never executed during pytest run - "if TYPE_CHECKING:", - "@overload", + # Have to re-enable the standard pragma + "pragma: no cover", + # Don't complain about missing debug-only code: + "def __repr__", + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + # TYPE_CHECKING and @overload blocks are never executed during pytest run + "if TYPE_CHECKING:", + "@overload", ] [tool.ruff] @@ -705,158 +736,158 @@ required-version = ">=0.11.0" [tool.ruff.lint] select = [ - "A001", # Variable {name} is shadowing a Python builtin - "ASYNC", # flake8-async - "B002", # Python does not support the unary prefix increment - "B005", # Using .strip() with multi-character strings is misleading - "B007", # Loop control variable {name} not used within loop body - "B014", # Exception handler with duplicate exception - "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. - "B017", # pytest.raises(BaseException) should be considered evil - "B018", # Found useless attribute access. Either assign it to a variable or remove it. - "B023", # Function definition does not bind loop variable {name} - "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties - "B026", # Star-arg unpacking after a keyword argument is strongly discouraged - "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? - "B035", # Dictionary comprehension uses static key - "B904", # Use raise from to specify exception cause - "B905", # zip() without an explicit strict= parameter - "BLE", - "C", # complexity - "COM818", # Trailing comma on bare tuple prohibited - "D", # docstrings - "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() - "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) - "E", # pycodestyle - "F", # pyflakes/autoflake - "F541", # f-string without any placeholders - "FLY", # flynt - "FURB", # refurb - "G", # flake8-logging-format - "I", # isort - "INP", # flake8-no-pep420 - "ISC", # flake8-implicit-str-concat - "ICN001", # import concentions; {name} should be imported as {asname} - "LOG", # flake8-logging - "N804", # First argument of a class method should be named cls - "N805", # First argument of a method should be named self - "N815", # Variable {name} in class scope should not be mixedCase - "PERF", # Perflint - "PGH", # pygrep-hooks - "PIE", # flake8-pie - "PL", # pylint - "PT", # flake8-pytest-style - "PTH", # flake8-pathlib - "PYI", # flake8-pyi - "RET", # flake8-return - "RSE", # flake8-raise - "RUF005", # Consider iterable unpacking instead of concatenation - "RUF006", # Store a reference to the return value of asyncio.create_task - "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs - "RUF008", # Do not use mutable default values for dataclass attributes - "RUF010", # Use explicit conversion flag - "RUF013", # PEP 484 prohibits implicit Optional - "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer - "RUF017", # Avoid quadratic list summation - "RUF018", # Avoid assignment expressions in assert statements - "RUF019", # Unnecessary key check before dictionary access - "RUF020", # {never_like} | T is equivalent to T - "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear - "RUF022", # Sort __all__ - "RUF023", # Sort __slots__ - "RUF024", # Do not pass mutable objects as values to dict.fromkeys - "RUF026", # default_factory is a positional-only argument to defaultdict - "RUF030", # print() call in assert statement is likely unintentional - "RUF032", # Decimal() called with float literal argument - "RUF033", # __post_init__ method with argument defaults - "RUF034", # Useless if-else condition - "RUF100", # Unused `noqa` directive - "RUF101", # noqa directives that use redirected rule codes - "RUF200", # Failed to parse pyproject.toml: {message} - "S102", # Use of exec detected - "S103", # bad-file-permissions - "S108", # hardcoded-temp-file - "S306", # suspicious-mktemp-usage - "S307", # suspicious-eval-usage - "S313", # suspicious-xmlc-element-tree-usage - "S314", # suspicious-xml-element-tree-usage - "S315", # suspicious-xml-expat-reader-usage - "S316", # suspicious-xml-expat-builder-usage - "S317", # suspicious-xml-sax-usage - "S318", # suspicious-xml-mini-dom-usage - "S319", # suspicious-xml-pull-dom-usage - "S601", # paramiko-call - "S602", # subprocess-popen-with-shell-equals-true - "S604", # call-with-shell-equals-true - "S608", # hardcoded-sql-expression - "S609", # unix-command-wildcard-injection - "SIM", # flake8-simplify - "SLF", # flake8-self - "SLOT", # flake8-slots - "T100", # Trace found: {name} used - "T20", # flake8-print - "TC", # flake8-type-checking - "TID", # Tidy imports - "TRY", # tryceratops - "UP", # pyupgrade - "UP031", # Use format specifiers instead of percent format - "UP032", # Use f-string instead of `format` call - "W", # pycodestyle + "A001", # Variable {name} is shadowing a Python builtin + "ASYNC", # flake8-async + "B002", # Python does not support the unary prefix increment + "B005", # Using .strip() with multi-character strings is misleading + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. + "B017", # pytest.raises(BaseException) should be considered evil + "B018", # Found useless attribute access. Either assign it to a variable or remove it. + "B023", # Function definition does not bind loop variable {name} + "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties + "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? + "B035", # Dictionary comprehension uses static key + "B904", # Use raise from to specify exception cause + "B905", # zip() without an explicit strict= parameter + "BLE", + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "E", # pycodestyle + "F", # pyflakes/autoflake + "F541", # f-string without any placeholders + "FLY", # flynt + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat + "ICN001", # import concentions; {name} should be imported as {asname} + "LOG", # flake8-logging + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N815", # Variable {name} in class scope should not be mixedCase + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-pathlib + "PYI", # flake8-pyi + "RET", # flake8-return + "RSE", # flake8-raise + "RUF005", # Consider iterable unpacking instead of concatenation + "RUF006", # Store a reference to the return value of asyncio.create_task + "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs + "RUF008", # Do not use mutable default values for dataclass attributes + "RUF010", # Use explicit conversion flag + "RUF013", # PEP 484 prohibits implicit Optional + "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer + "RUF017", # Avoid quadratic list summation + "RUF018", # Avoid assignment expressions in assert statements + "RUF019", # Unnecessary key check before dictionary access + "RUF020", # {never_like} | T is equivalent to T + "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear + "RUF022", # Sort __all__ + "RUF023", # Sort __slots__ + "RUF024", # Do not pass mutable objects as values to dict.fromkeys + "RUF026", # default_factory is a positional-only argument to defaultdict + "RUF030", # print() call in assert statement is likely unintentional + "RUF032", # Decimal() called with float literal argument + "RUF033", # __post_init__ method with argument defaults + "RUF034", # Useless if-else condition + "RUF100", # Unused `noqa` directive + "RUF101", # noqa directives that use redirected rule codes + "RUF200", # Failed to parse pyproject.toml: {message} + "S102", # Use of exec detected + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T100", # Trace found: {name} used + "T20", # flake8-print + "TC", # flake8-type-checking + "TID", # Tidy imports + "TRY", # tryceratops + "UP", # pyupgrade + "UP031", # Use format specifiers instead of percent format + "UP032", # Use f-string instead of `format` call + "W", # pycodestyle ] ignore = [ - "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead - "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop - "D202", # No blank lines allowed after function docstring - "D203", # 1 blank line required before class docstring - "D213", # Multi-line docstring summary should start at the second line - "D406", # Section name should end with a newline - "D407", # Section name underlining - "E501", # line too long + "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead + "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long - "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives - "PLR0911", # Too many return statements ({returns} > {max_returns}) - "PLR0912", # Too many branches ({branches} > {max_branches}) - "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) - "PLR0915", # Too many statements ({statements} > {max_statements}) - "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable - "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target - "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception - "PT018", # Assertion should be broken down into multiple parts - "RUF001", # String contains ambiguous unicode character. - "RUF002", # Docstring contains ambiguous unicode character. - "RUF003", # Comment contains ambiguous unicode character. - "RUF015", # Prefer next(...) over single element slice - "SIM102", # Use a single if statement instead of nested if statements - "SIM103", # Return the condition {condition} directly - "SIM108", # Use ternary operator {contents} instead of if-else-block - "SIM115", # Use context handler for opening files + "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives + "PLR0911", # Too many return statements ({returns} > {max_returns}) + "PLR0912", # Too many branches ({branches} > {max_branches}) + "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) + "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target + "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception + "PT018", # Assertion should be broken down into multiple parts + "RUF001", # String contains ambiguous unicode character. + "RUF002", # Docstring contains ambiguous unicode character. + "RUF003", # Comment contains ambiguous unicode character. + "RUF015", # Prefer next(...) over single element slice + "SIM102", # Use a single if statement instead of nested if statements + "SIM103", # Return the condition {condition} directly + "SIM108", # Use ternary operator {contents} instead of if-else-block + "SIM115", # Use context handler for opening files - # Moving imports into type-checking blocks can mess with pytest.patch() - "TC001", # Move application import {} into a type-checking block - "TC002", # Move third-party import {} into a type-checking block - "TC003", # Move standard library import {} into a type-checking block - # Quotes for typing.cast generally not necessary, only for performance critical paths - "TC006", # Add quotes to type expression in typing.cast() + # Moving imports into type-checking blocks can mess with pytest.patch() + "TC001", # Move application import {} into a type-checking block + "TC002", # Move third-party import {} into a type-checking block + "TC003", # Move standard library import {} into a type-checking block + # Quotes for typing.cast generally not necessary, only for performance critical paths + "TC006", # Add quotes to type expression in typing.cast() - "TRY003", # Avoid specifying long messages outside the exception class - "TRY400", # Use `logging.exception` instead of `logging.error` - # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 - "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + "TRY003", # Avoid specifying long messages outside the exception class + "TRY400", # Use `logging.exception` instead of `logging.error` + # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` - # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules - "W191", - "E111", - "E114", - "E117", - "D206", - "D300", - "Q", - "COM812", - "COM819", + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q", + "COM812", + "COM819", - # Disabled because ruff does not understand type of __all__ generated by a function - "PLE0605", + # Disabled because ruff does not understand type of __all__ generated by a function + "PLE0605", ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] @@ -932,9 +963,7 @@ mark-parentheses = false [tool.ruff.lint.isort] force-sort-within-sections = true -known-first-party = [ - "homeassistant", -] +known-first-party = ["homeassistant"] combine-as-imports = true split-on-trailing-comma = false diff --git a/requirements.txt b/requirements.txt index 0735e38c89c..b07a8710e5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,12 +5,12 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp==3.11.14 +aiohttp==3.11.16 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.4.4 +annotatedyaml==0.4.5 astral==2.2 async-interrupt==1.2.2 attrs==25.1.0 @@ -22,34 +22,42 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.4.0 +ha-ffmpeg==3.2.2 hass-nabucasa==0.94.0 +hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 +home-assistant-intents==2025.3.28 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 +mutagen==1.47.0 +numpy==2.2.2 PyJWT==2.10.1 cryptography==44.0.1 Pillow==11.1.0 -propcache==0.3.0 +propcache==0.3.1 pyOpenSSL==25.0.0 -orjson==3.10.15 +orjson==3.10.16 packaging>=23.1 psutil-home-assistant==0.0.1 +pymicro-vad==1.0.1 +pyspeex-noise==1.0.2 python-slugify==8.0.4 +PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 standard-aifc==3.13.0 standard-telnetlib==3.13.0 -typing-extensions>=4.12.2,<5.0 +typing-extensions>=4.13.0,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.8 +uv==0.6.10 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 -yarl==1.18.3 +yarl==1.19.0 webrtc-models==0.3.0 zeroconf==0.146.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9848158a10e..658b20f6245 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==14.0.6 +PyChromecast==14.0.7 # homeassistant.components.flick_electric PyFlick==1.1.3 @@ -70,7 +70,7 @@ PyMetno==0.13.0 PyMicroBot==0.0.17 # homeassistant.components.nina -PyNINA==0.3.4 +PyNINA==0.3.5 # homeassistant.components.mobile_app # homeassistant.components.owntracks @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.57.1 +PySwitchbot==0.59.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 @@ -179,10 +179,10 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.10 +aioairzone-cloud==0.6.11 # homeassistant.components.airzone -aioairzone==0.9.9 +aioairzone==1.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.3.1 +aioautomower==2025.3.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -210,10 +210,10 @@ aioazuredevops==2.2.1 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.13.1 +aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==0.11.2 +aiocomelit==0.11.3 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 @@ -225,7 +225,7 @@ aiodiscover==2.6.1 aiodns==3.2.0 # homeassistant.components.duke_energy -aiodukeenergy==0.2.2 +aiodukeenergy==0.3.0 # homeassistant.components.eafm aioeafm==0.1.2 @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.7.0 +aioesphomeapi==29.9.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -264,10 +264,10 @@ aioharmony==0.5.2 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.16.3 +aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.8 +aiohomekit==3.2.13 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 @@ -362,7 +362,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.4.0 +aiorussound==4.5.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.2 +aiowebdav2==0.4.5 # homeassistant.components.webostv aiowebostv==0.7.3 @@ -464,7 +464,7 @@ amcrest==1.9.8 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.0 +androidtvremote2==0.2.1 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 @@ -479,7 +479,7 @@ anthemav==1.4.1 anthropic==0.47.2 # homeassistant.components.mcp_server -anyio==4.8.0 +anyio==4.9.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 @@ -491,7 +491,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.4.0 +apsystems-ez1==2.5.0 # homeassistant.components.aqualogic aqualogic==2.6 @@ -514,7 +514,7 @@ asmog==0.0.6 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.12.0 +bleak-esphome==2.13.1 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 @@ -627,7 +627,6 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.decora -# homeassistant.components.zengge # bluepy==1.3.0 # homeassistant.components.bluetooth @@ -640,20 +639,23 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.1 +bluetooth-data-tools==1.27.0 # homeassistant.components.bond bond-async==0.2.1 +# homeassistant.components.bosch_alarm +bosch-alarm-mode2==0.4.3 + # homeassistant.components.bosch_shc boschshcpy==0.2.91 # homeassistant.components.amazon_polly # homeassistant.components.route53 -boto3==1.34.131 +boto3==1.37.1 # homeassistant.components.aws -botocore==1.34.131 +botocore==1.37.1 # homeassistant.components.bring bring-api==1.1.0 @@ -744,7 +746,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.6 +dbus-fast==2.43.0 # homeassistant.components.debugpy debugpy==1.8.13 @@ -756,7 +758,7 @@ debugpy==1.8.13 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.3.1 +deebot-client==12.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -800,7 +802,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.4.2 +dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 @@ -899,7 +901,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==1.0.4 +evohome-async==1.0.5 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 @@ -942,7 +944,7 @@ flexit_bacnet==2.2.3 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.3 +flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder @@ -952,7 +954,7 @@ fnv-hash-fast==1.4.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==4.0.0 +forecast-solar==4.1.0 # homeassistant.components.fortios fortiosapi==1.0.5 @@ -977,7 +979,7 @@ gTTS==2.5.3 gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.11 +gassist-text==0.0.12 # homeassistant.components.google gcal-sync==7.0.0 @@ -1030,16 +1032,16 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.28.0 +google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud -google-cloud-speech==2.27.0 +google-cloud-speech==2.31.1 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.17.2 +google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation -google-genai==1.1.0 +google-genai==1.7.0 # homeassistant.components.nest google-nest-sdm==7.1.4 @@ -1112,7 +1114,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.32.0 +habluetooth==3.37.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 @@ -1152,13 +1154,13 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.68 +holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250306.0 +home-assistant-frontend==20250404.0 # homeassistant.components.conversation -home-assistant-intents==2025.3.5 +home-assistant-intents==2025.3.28 # homeassistant.components.homematicip_cloud homematicip==1.1.7 @@ -1182,7 +1184,7 @@ hyperion-py==0.7.5 iammeter==0.2.1 # homeassistant.components.iaqualink -iaqualink==0.5.0 +iaqualink==0.5.3 # homeassistant.components.ibeacon ibeacon-ble==1.2.0 @@ -1194,7 +1196,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.0.1 +ical==9.1.0 # homeassistant.components.caldav icalendar==6.1.0 @@ -1218,7 +1220,7 @@ igloohome-api==0.1.0 ihcsdk==2.8.5 # homeassistant.components.imgw_pib -imgw_pib==1.0.9 +imgw_pib==1.0.10 # homeassistant.components.incomfort incomfort-client==0.6.7 @@ -1230,7 +1232,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.9.0 +inkbird-ble==0.10.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1382,7 +1384,7 @@ mbddns==0.1.2 # homeassistant.components.mcp # homeassistant.components.mcp_server -mcp==1.1.2 +mcp==1.5.0 # homeassistant.components.minecraft_server mcstatus==11.1.1 @@ -1403,7 +1405,7 @@ messagebird==1.2.0 meteoalertapi==0.3.1 # homeassistant.components.meteo_france -meteofrance-api==1.3.0 +meteofrance-api==1.4.0 # homeassistant.components.mfi mficlient==0.5.0 @@ -1451,7 +1453,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.1.1 +music-assistant-client==1.2.0 # homeassistant.components.tts mutagen==1.47.0 @@ -1557,7 +1559,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.4.1 +ohme==1.5.1 # homeassistant.components.ollama ollama==0.4.7 @@ -1581,7 +1583,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.66.3 +openai==1.68.2 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1605,7 +1607,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.9.0 +opower==0.10.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1673,7 +1675,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.2 +plugwise==1.7.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1708,7 +1710,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.1.1 +psutil==7.0.0 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 @@ -1720,7 +1722,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.2.0 +pvo==2.2.1 # homeassistant.components.aosmith py-aosmith==1.0.12 @@ -1734,6 +1736,9 @@ py-ccm15==0.0.9 # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 +# homeassistant.components.pterodactyl +py-dactyl==2.0.4 + # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 @@ -1817,7 +1822,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.7 +pyaprilaire==0.8.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 @@ -1886,7 +1891,7 @@ pycsspeechtts==1.0.8 # pycups==2.0.4 # homeassistant.components.daikin -pydaikin==2.14.1 +pydaikin==2.15.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 @@ -1895,7 +1900,7 @@ pydanfossair==0.1.0 pydeako==0.6.0 # homeassistant.components.deconz -pydeconz==118 +pydeconz==120 # homeassistant.components.delijn pydelijn==1.1.0 @@ -1913,7 +1918,7 @@ pydoods==1.0.2 pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam -pydroid-ipcam==2.0.0 +pydroid-ipcam==3.0.0 # homeassistant.components.ebox pyebox==1.1.4 @@ -1943,7 +1948,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.25.1 +pyenphase==1.25.5 # homeassistant.components.envisalink pyenvisalink==4.7 @@ -2000,7 +2005,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.3 +pyheos==1.0.4 # homeassistant.components.hive pyhive-integration==1.0.2 @@ -2042,7 +2047,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.14 +pyisy==3.4.0 # homeassistant.components.itach pyitachip2ir==0.0.7 @@ -2072,7 +2077,7 @@ pykoplenti==1.3.0 pykrakenapi==0.1.8 # homeassistant.components.kulersky -pykulersky==0.5.2 +pykulersky==0.5.8 # homeassistant.components.kwb pykwb==0.0.8 @@ -2203,7 +2208,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.3 +pyoverkiz==1.16.5 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -2218,7 +2223,7 @@ pypca==0.0.7 pypck==0.8.5 # homeassistant.components.pglab -pypglab==0.0.3 +pypglab==0.0.5 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -2284,7 +2289,7 @@ pyschlage==2024.11.0 pysensibo==1.1.0 # homeassistant.components.serial -pyserial-asyncio-fast==0.14 +pyserial-asyncio-fast==0.16 # homeassistant.components.acer_projector # homeassistant.components.crownstone @@ -2314,19 +2319,19 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.4 +pysmartthings==3.0.2 # homeassistant.components.smarty pysmarty2==0.10.2 # homeassistant.components.smhi -pysmhi==1.0.0 +pysmhi==1.0.1 # homeassistant.components.edl21 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.3 +pysmlight==0.2.4 # homeassistant.components.snmp pysnmp==6.2.6 @@ -2425,7 +2430,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.1.3 +python-linkplay==0.2.2 # homeassistant.components.lirc # python-lirc==1.2.3 @@ -2456,7 +2461,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api2==1.2.2 +python-picnic-api2==1.2.4 # homeassistant.components.rabbitair python-rabbitair==0.0.8 @@ -2465,19 +2470,19 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.14.0 +python-roborock==2.16.1 # homeassistant.components.smarttub python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.3 +python-snoo==0.6.5 # homeassistant.components.songpal python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.6 +python-tado==0.18.11 # homeassistant.components.technove python-technove==2.0.0 @@ -2559,7 +2564,7 @@ pywemo==1.4.0 pywilight==0.0.74 # homeassistant.components.wiz -pywizlight==0.5.14 +pywizlight==0.6.2 # homeassistant.components.wmspro pywmspro==0.2.1 @@ -2622,7 +2627,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.3 +reolink-aio==0.13.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2707,7 +2712,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush_cloud -sensorpush-api==2.1.1 +sensorpush-api==2.1.2 # homeassistant.components.sensorpush sensorpush-ble==1.7.1 @@ -2825,9 +2830,6 @@ stringcase==1.2.0 # homeassistant.components.subaru subarulink==0.7.13 -# homeassistant.components.sunweg -sunweg==3.0.2 - # homeassistant.components.surepetcare surepy==0.9.0 @@ -2876,7 +2878,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.13 +tesla-fleet-api==1.0.17 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2885,7 +2887,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.12 +teslemetry-stream==0.7.1 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2989,7 +2991,7 @@ upcloud-api==2.6.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru # homeassistant.components.zwave_me -url-normalize==1.4.3 +url-normalize==2.2.0 # homeassistant.components.uvc uvcclient==0.12.1 @@ -3065,7 +3067,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.2.26 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.19.1 +whirlpool-sixth-sense==0.20.0 # homeassistant.components.whois whois==0.9.27 @@ -3089,7 +3091,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.33.0 +xiaomi-ble==0.35.0 # homeassistant.components.knx xknx==3.6.0 @@ -3126,7 +3128,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.4.8 +yolink-api==0.4.9 # homeassistant.components.youless youless-api==2.2.0 @@ -3135,7 +3137,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.02.19 +yt-dlp[default]==2025.03.26 # homeassistant.components.zabbix zabbix-utils==2.0.2 @@ -3143,9 +3145,6 @@ zabbix-utils==2.0.2 # homeassistant.components.zamg zamg==0.3.6 -# homeassistant.components.zengge -zengge==0.2 - # homeassistant.components.zeroconf zeroconf==0.146.0 @@ -3153,7 +3152,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.52 +zha==0.0.55 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test.txt b/requirements_test.txt index baf72265c40..b53b1fd8840 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,13 +12,13 @@ coverage==7.6.12 freezegun==1.5.1 license-expression==30.4.1 mock-open==1.4.0 -mypy-dev==1.16.0a5 +mypy-dev==1.16.0a7 pre-commit==4.0.0 -pydantic==2.10.6 +pydantic==2.11.2 pylint==3.3.6 pylint-per-file-ignores==1.4.0 pipdeptree==2.25.1 -pytest-asyncio==0.25.3 +pytest-asyncio==0.26.0 pytest-aiohttp==1.1.0 pytest-cov==6.0.0 pytest-freezer==0.4.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc2b8acc214..7a3e632559c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -42,7 +42,7 @@ PlexAPI==4.15.16 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==14.0.6 +PyChromecast==14.0.7 # homeassistant.components.flick_electric PyFlick==1.1.3 @@ -67,7 +67,7 @@ PyMetno==0.13.0 PyMicroBot==0.0.17 # homeassistant.components.nina -PyNINA==0.3.4 +PyNINA==0.3.5 # homeassistant.components.mobile_app # homeassistant.components.owntracks @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.57.1 +PySwitchbot==0.59.0 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -110,7 +110,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 @@ -167,10 +167,10 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.10 +aioairzone-cloud==0.6.11 # homeassistant.components.airzone -aioairzone==0.9.9 +aioairzone==1.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.3.1 +aioautomower==2025.3.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -198,10 +198,10 @@ aioazuredevops==2.2.1 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.13.1 +aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==0.11.2 +aiocomelit==0.11.3 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 @@ -213,7 +213,7 @@ aiodiscover==2.6.1 aiodns==3.2.0 # homeassistant.components.duke_energy -aiodukeenergy==0.2.2 +aiodukeenergy==0.3.0 # homeassistant.components.eafm aioeafm==0.1.2 @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.7.0 +aioesphomeapi==29.9.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -249,10 +249,10 @@ aioharmony==0.5.2 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.16.3 +aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.8 +aiohomekit==3.2.13 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 @@ -344,7 +344,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.4.0 +aiorussound==4.5.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.2 +aiowebdav2==0.4.5 # homeassistant.components.webostv aiowebostv==0.7.3 @@ -440,7 +440,7 @@ amberelectric==2.0.12 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.0 +androidtvremote2==0.2.1 # homeassistant.components.anova anova-wifi==0.17.0 @@ -452,7 +452,7 @@ anthemav==1.4.1 anthropic==0.47.2 # homeassistant.components.mcp_server -anyio==4.8.0 +anyio==4.9.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 @@ -464,7 +464,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.4.0 +apsystems-ez1==2.5.0 # homeassistant.components.aranet aranet4==2.5.1 @@ -478,7 +478,7 @@ arcam-fmj==1.8.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.12.0 +bleak-esphome==2.13.1 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 @@ -564,16 +564,19 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.1 +bluetooth-data-tools==1.27.0 # homeassistant.components.bond bond-async==0.2.1 +# homeassistant.components.bosch_alarm +bosch-alarm-mode2==0.4.3 + # homeassistant.components.bosch_shc boschshcpy==0.2.91 # homeassistant.components.aws -botocore==1.34.131 +botocore==1.37.1 # homeassistant.components.bring bring-api==1.1.0 @@ -640,13 +643,13 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.6 +dbus-fast==2.43.0 # homeassistant.components.debugpy debugpy==1.8.13 # homeassistant.components.ecovacs -deebot-client==12.3.1 +deebot-client==12.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -687,7 +690,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.4.2 +dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 @@ -765,7 +768,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==1.0.4 +evohome-async==1.0.5 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 @@ -801,7 +804,7 @@ flexit_bacnet==2.2.3 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.3 +flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder @@ -811,7 +814,7 @@ fnv-hash-fast==1.4.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==4.0.0 +forecast-solar==4.1.0 # homeassistant.components.freebox freebox-api==1.2.2 @@ -830,7 +833,7 @@ gTTS==2.5.3 gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.11 +gassist-text==0.0.12 # homeassistant.components.google gcal-sync==7.0.0 @@ -880,16 +883,16 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.28.0 +google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud -google-cloud-speech==2.27.0 +google-cloud-speech==2.31.1 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.17.2 +google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation -google-genai==1.1.0 +google-genai==1.7.0 # homeassistant.components.nest google-nest-sdm==7.1.4 @@ -946,11 +949,14 @@ ha-iotawattpy==0.1.2 # homeassistant.components.philips_js ha-philipsjs==3.2.2 +# homeassistant.components.homeassistant_hardware +ha-silabs-firmware-client==0.2.0 + # homeassistant.components.habitica habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.32.0 +habluetooth==3.37.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 @@ -978,13 +984,13 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.68 +holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250306.0 +home-assistant-frontend==20250404.0 # homeassistant.components.conversation -home-assistant-intents==2025.3.5 +home-assistant-intents==2025.3.28 # homeassistant.components.homematicip_cloud homematicip==1.1.7 @@ -1002,7 +1008,7 @@ huum==0.7.12 hyperion-py==0.7.5 # homeassistant.components.iaqualink -iaqualink==0.5.0 +iaqualink==0.5.3 # homeassistant.components.ibeacon ibeacon-ble==1.2.0 @@ -1011,7 +1017,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.0.1 +ical==9.1.0 # homeassistant.components.caldav icalendar==6.1.0 @@ -1029,7 +1035,7 @@ ifaddr==0.2.0 igloohome-api==0.1.0 # homeassistant.components.imgw_pib -imgw_pib==1.0.9 +imgw_pib==1.0.10 # homeassistant.components.incomfort incomfort-client==0.6.7 @@ -1041,7 +1047,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.9.0 +inkbird-ble==0.10.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1157,7 +1163,7 @@ mbddns==0.1.2 # homeassistant.components.mcp # homeassistant.components.mcp_server -mcp==1.1.2 +mcp==1.5.0 # homeassistant.components.minecraft_server mcstatus==11.1.1 @@ -1172,7 +1178,7 @@ medcom-ble==0.1.1 melnor-bluetooth==0.0.25 # homeassistant.components.meteo_france -meteofrance-api==1.3.0 +meteofrance-api==1.4.0 # homeassistant.components.mfi mficlient==0.5.0 @@ -1220,7 +1226,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.1.1 +music-assistant-client==1.2.0 # homeassistant.components.tts mutagen==1.47.0 @@ -1302,7 +1308,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.4.1 +ohme==1.5.1 # homeassistant.components.ollama ollama==0.4.7 @@ -1326,7 +1332,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.66.3 +openai==1.68.2 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1338,7 +1344,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.9.0 +opower==0.10.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1383,7 +1389,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.2 +plugwise==1.7.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1409,7 +1415,7 @@ prometheus-client==0.21.0 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.1.1 +psutil==7.0.0 # homeassistant.components.pushbullet pushbullet.py==0.11.0 @@ -1418,7 +1424,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.2.0 +pvo==2.2.1 # homeassistant.components.aosmith py-aosmith==1.0.12 @@ -1432,6 +1438,9 @@ py-ccm15==0.0.9 # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 +# homeassistant.components.pterodactyl +py-dactyl==2.0.4 + # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 @@ -1494,7 +1503,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.7 +pyaprilaire==0.8.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 @@ -1542,13 +1551,13 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.14.1 +pydaikin==2.15.0 # homeassistant.components.deako pydeako==0.6.0 # homeassistant.components.deconz -pydeconz==118 +pydeconz==120 # homeassistant.components.dexcom pydexcom==0.2.3 @@ -1560,7 +1569,7 @@ pydiscovergy==3.0.2 pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam -pydroid-ipcam==2.0.0 +pydroid-ipcam==3.0.0 # homeassistant.components.ecoforest pyecoforest==0.4.0 @@ -1584,7 +1593,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.25.1 +pyenphase==1.25.5 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1626,7 +1635,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.3 +pyheos==1.0.4 # homeassistant.components.hive pyhive-integration==1.0.2 @@ -1662,7 +1671,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.14 +pyisy==3.4.0 # homeassistant.components.ituran pyituran==0.1.4 @@ -1689,7 +1698,7 @@ pykoplenti==1.3.0 pykrakenapi==0.1.8 # homeassistant.components.kulersky -pykulersky==0.5.2 +pykulersky==0.5.8 # homeassistant.components.lamarzocco pylamarzocco==1.4.9 @@ -1796,7 +1805,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.3 +pyoverkiz==1.16.5 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -1808,7 +1817,7 @@ pypalazzetti==0.1.19 pypck==0.8.5 # homeassistant.components.pglab -pypglab==0.0.3 +pypglab==0.0.5 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -1883,19 +1892,19 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.4 +pysmartthings==3.0.2 # homeassistant.components.smarty pysmarty2==0.10.2 # homeassistant.components.smhi -pysmhi==1.0.0 +pysmhi==1.0.1 # homeassistant.components.edl21 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.3 +pysmlight==0.2.4 # homeassistant.components.snmp pysnmp==6.2.6 @@ -1961,7 +1970,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.1.3 +python-linkplay==0.2.2 # homeassistant.components.matter python-matter-server==7.0.0 @@ -1989,25 +1998,25 @@ python-otbr-api==2.7.0 python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api2==1.2.2 +python-picnic-api2==1.2.4 # homeassistant.components.rabbitair python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.14.0 +python-roborock==2.16.1 # homeassistant.components.smarttub python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.3 +python-snoo==0.6.5 # homeassistant.components.songpal python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.6 +python-tado==0.18.11 # homeassistant.components.technove python-technove==2.0.0 @@ -2074,7 +2083,7 @@ pywemo==1.4.0 pywilight==0.0.74 # homeassistant.components.wiz -pywizlight==0.5.14 +pywizlight==0.6.2 # homeassistant.components.wmspro pywmspro==0.2.1 @@ -2122,7 +2131,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.3 +reolink-aio==0.13.0 # homeassistant.components.rflink rflink==0.0.66 @@ -2183,7 +2192,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush_cloud -sensorpush-api==2.1.1 +sensorpush-api==2.1.2 # homeassistant.components.sensorpush sensorpush-ble==1.7.1 @@ -2283,9 +2292,6 @@ stringcase==1.2.0 # homeassistant.components.subaru subarulink==0.7.13 -# homeassistant.components.sunweg -sunweg==3.0.2 - # homeassistant.components.surepetcare surepy==0.9.0 @@ -2313,7 +2319,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.13 +tesla-fleet-api==1.0.17 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2322,7 +2328,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.12 +teslemetry-stream==0.7.1 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2393,6 +2399,9 @@ ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.2.0 +# homeassistant.components.homeassistant_hardware +universal-silabs-flasher==0.0.30 + # homeassistant.components.upb upb-lib==0.6.1 @@ -2402,7 +2411,7 @@ upcloud-api==2.6.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru # homeassistant.components.zwave_me -url-normalize==1.4.3 +url-normalize==2.2.0 # homeassistant.components.uvc uvcclient==0.12.1 @@ -2466,7 +2475,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.2.26 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.19.1 +whirlpool-sixth-sense==0.20.0 # homeassistant.components.whois whois==0.9.27 @@ -2487,7 +2496,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.33.0 +xiaomi-ble==0.35.0 # homeassistant.components.knx xknx==3.6.0 @@ -2518,7 +2527,7 @@ yalexs==8.10.0 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.4.8 +yolink-api==0.4.9 # homeassistant.components.youless youless-api==2.2.0 @@ -2527,7 +2536,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.02.19 +yt-dlp[default]==2025.03.26 # homeassistant.components.zamg zamg==0.3.6 @@ -2539,7 +2548,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.52 +zha==0.0.55 # homeassistant.components.zwave_js zwave-js-server-python==0.62.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fa823fa4834..acc87ec2731 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -117,9 +117,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.67.1 -grpcio-status==1.67.1 -grpcio-reflection==1.67.1 +grpcio==1.71.0 +grpcio-status==1.71.0 +grpcio-reflection==1.71.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -139,7 +139,7 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.8.0 +anyio==4.9.0 h11==0.14.0 httpcore==1.0.7 @@ -159,7 +159,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.6 +pydantic==2.11.2 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -266,7 +266,8 @@ def has_tests(module: str) -> bool: Test if exists: tests/components/hue/__init__.py """ path = ( - Path(module.replace(".", "/").replace("homeassistant", "tests")) / "__init__.py" + Path(module.replace(".", "/").replace("homeassistant", "tests", 1)) + / "__init__.py" ) return path.exists() diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index f842ec61b97..1f8b7d1139b 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -95,7 +95,6 @@ def _populate_brand_integrations( integration = integrations.get(domain) if not integration or integration.integration_type in ( "entity", - "hardware", "system", ): continue @@ -171,7 +170,7 @@ def _generate_integrations( result["integration"][domain] = metadata else: # integration integration = integrations[domain] - if integration.integration_type in ("entity", "system", "hardware"): + if integration.integration_type in ("entity", "system"): continue if integration.translated_name: diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index b22027500dd..52ea79d32fe 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -173,10 +173,11 @@ IGNORE_VIOLATIONS = { "logbook", # Temporary needed for migration until 2024.10 ("conversation", "assist_pipeline"), - # The onboarding integration provides a limited backup API used during - # onboarding. The onboarding integration waits for the backup manager - # to be ready before calling any backup functionality. + # The onboarding integration provides limited backup and cloud APIs for use + # during onboarding. The onboarding integration waits for the backup manager + # and cloud to be ready before calling any backup or cloud functionality. ("onboarding", "backup"), + ("onboarding", "cloud"), } diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 79716b6fec3..bfdb61096b6 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.6.8,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.6.10,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.8,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.11.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index cdd062d2f4c..49da98f5872 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -513,7 +513,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "iglo", "ign_sismologia", "ihc", - "imgw_pib", "improv_ble", "influxdb", "inkbird", @@ -896,7 +895,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "sfr_box", "sharkiq", "shell_command", - "shelly", "shodan", "shopping_list", "sia", @@ -920,7 +918,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "sma", "smappee", "smart_meter_texas", - "smartthings", "smarttub", "smarty", "smhi", @@ -1575,7 +1572,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "ign_sismologia", "ihc", "imap", - "imgw_pib", "improv_ble", "influxdb", "inkbird", @@ -1928,7 +1924,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "risco", "rituals_perfume_genie", "rmvtransport", - "roborock", "rocketchat", "roku", "romy", @@ -1958,7 +1953,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "season", "sendgrid", "sense", - "sensibo", "sensirion_ble", "sensorpro", "sensorpush", @@ -1997,7 +1991,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "sma", "smappee", "smart_meter_texas", - "smartthings", "smarttub", "smarty", "smhi", diff --git a/script/quality_scale_summary.py b/script/quality_scale_summary.py new file mode 100644 index 00000000000..b93eab81451 --- /dev/null +++ b/script/quality_scale_summary.py @@ -0,0 +1,89 @@ +"""Generate a summary of integration quality scales. + +Run with python3 -m script.quality_scale_summary +Data collected at https://docs.google.com/spreadsheets/d/1xEiwovRJyPohAv8S4ad2LAB-0A38s1HWmzHng8v-4NI +""" + +import csv +from pathlib import Path +import sys + +from homeassistant.const import __version__ as current_version +from homeassistant.util.json import load_json + +COMPONENTS_DIR = Path("homeassistant/components") + + +def generate_quality_scale_summary() -> list[str, int]: + """Generate a summary of integration quality scales.""" + quality_scales = { + "virtual": 0, + "unknown": 0, + "legacy": 0, + "internal": 0, + "bronze": 0, + "silver": 0, + "gold": 0, + "platinum": 0, + } + + for manifest_path in COMPONENTS_DIR.glob("*/manifest.json"): + manifest = load_json(manifest_path) + + if manifest.get("integration_type") == "virtual": + quality_scales["virtual"] += 1 + elif quality_scale := manifest.get("quality_scale"): + quality_scales[quality_scale] += 1 + else: + quality_scales["unknown"] += 1 + + return quality_scales + + +def output_csv(quality_scales: dict[str, int], print_header: bool) -> None: + """Output the quality scale summary as CSV.""" + writer = csv.writer(sys.stdout) + if print_header: + writer.writerow( + [ + "Version", + "Total", + "Virtual", + "Unknown", + "Legacy", + "Internal", + "Bronze", + "Silver", + "Gold", + "Platinum", + ] + ) + + # Calculate total + total = sum(quality_scales.values()) + + # Write the summary + writer.writerow( + [ + current_version, + total, + quality_scales["virtual"], + quality_scales["unknown"], + quality_scales["legacy"], + quality_scales["internal"], + quality_scales["bronze"], + quality_scales["silver"], + quality_scales["gold"], + quality_scales["platinum"], + ] + ) + + +def main() -> None: + """Run the script.""" + quality_scales = generate_quality_scale_summary() + output_csv(quality_scales, "--header" in sys.argv) + + +if __name__ == "__main__": + main() diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index dd2ce65b480..42a5ba80643 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -19,18 +19,18 @@ from homeassistant.setup import async_setup_component @pytest.fixture -def data(hass: HomeAssistant) -> hass_auth.Data: +async def data(hass: HomeAssistant) -> hass_auth.Data: """Create a loaded data class.""" data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) + await data.async_load() return data @pytest.fixture -def legacy_data(hass: HomeAssistant) -> hass_auth.Data: +async def legacy_data(hass: HomeAssistant) -> hass_auth.Data: """Create a loaded legacy data class.""" data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) + await data.async_load() data.is_legacy = True return data diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index b4976c07e1b..09dea8c354c 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -44,9 +44,11 @@ }), dict({ 'air_demand': 1, + 'battery': 99, 'coldStage': 1, 'coldStages': 1, 'coldangle': 2, + 'coverage': 72, 'errors': list([ ]), 'floor_demand': 1, @@ -73,9 +75,11 @@ }), dict({ 'air_demand': 0, + 'battery': 35, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 60, 'errors': list([ ]), 'floor_demand': 0, @@ -100,9 +104,11 @@ }), dict({ 'air_demand': 0, + 'battery': 25, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 88, 'errors': list([ dict({ 'Zone': 'Low battery', @@ -130,9 +136,11 @@ }), dict({ 'air_demand': 0, + 'battery': 80, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 66, 'errors': list([ ]), 'floor_demand': 0, @@ -497,9 +505,11 @@ 'temp-set': 19.2, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 99, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 72, }), '1:3': dict({ 'absolute-temp-max': 30.0, @@ -546,9 +556,11 @@ 'temp-set': 19.3, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 35, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 60, }), '1:4': dict({ 'absolute-temp-max': 86.0, @@ -597,9 +609,11 @@ 'temp-set': 66.9, 'temp-step': 1.0, 'temp-unit': 1, + 'thermostat-battery': 25, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 88, }), '1:5': dict({ 'absolute-temp-max': 30.0, @@ -645,9 +659,11 @@ 'temp-set': 19.5, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 80, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 66, }), '2:1': dict({ 'absolute-temp-max': 30.0, diff --git a/tests/components/airzone/snapshots/test_sensor.ambr b/tests/components/airzone/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..01ebf35b282 --- /dev/null +++ b/tests/components/airzone/snapshots/test_sensor.ambr @@ -0,0 +1,1245 @@ +# serializer version: 1 +# name: test_airzone_create_sensors[sensor.airzone_2_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_2_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_2:1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Airzone 2:1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.airzone_2_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_2_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_2:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airzone 2:1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airzone_2_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.3', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_dhw_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_dhw_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_dhw_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_dhw_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airzone DHW Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airzone_dhw_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_webserver_rssi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airzone_webserver_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rssi', + 'unique_id': 'airzone_unique_id_ws_wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_webserver_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Airzone WebServer RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.airzone_webserver_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-42', + }) +# --- +# name: test_airzone_create_sensors[sensor.aux_heat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aux_heat_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_4:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.aux_heat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Aux Heat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aux_heat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Despacho Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Despacho Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.despacho_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:4_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Despacho Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Despacho Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.despacho_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.20', + }) +# --- +# name: test_airzone_create_sensors[sensor.dkn_plus_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dkn_plus_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_3:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dkn_plus_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'DKN Plus Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dkn_plus_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.7', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm #1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm #1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_1_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:3_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm #1 Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm #1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.8', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm #2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm #2 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_2_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:5_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm #2 Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm #2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.5', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm Ppal Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm Ppal Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_ppal_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:2_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm Ppal Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm Ppal Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.1', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.salon_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Salon Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.salon_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.salon_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Salon Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.salon_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.6', + }) +# --- diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 352994d6313..b226be8ac78 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -1,14 +1,17 @@ """The sensor tests for the Airzone platform.""" +from collections.abc import Generator import copy from unittest.mock import patch from aioairzone.const import API_DATA, API_SYSTEMS import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airzone.coordinator import SCAN_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow from .util import ( @@ -20,62 +23,27 @@ from .util import ( async_init_integration, ) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.airzone.PLATFORMS", [Platform.SENSOR]): + yield @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_airzone_create_sensors(hass: HomeAssistant) -> None: +async def test_airzone_create_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: """Test creation of sensors.""" - await async_init_integration(hass) + config_entry = await async_init_integration(hass) - # Hot Water - state = hass.states.get("sensor.airzone_dhw_temperature") - assert state.state == "43" - - # WebServer - state = hass.states.get("sensor.airzone_webserver_rssi") - assert state.state == "-42" - - # Zones - state = hass.states.get("sensor.despacho_temperature") - assert state.state == "21.20" - - state = hass.states.get("sensor.despacho_humidity") - assert state.state == "36" - - state = hass.states.get("sensor.dorm_1_temperature") - assert state.state == "20.8" - - state = hass.states.get("sensor.dorm_1_humidity") - assert state.state == "35" - - state = hass.states.get("sensor.dorm_2_temperature") - assert state.state == "20.5" - - state = hass.states.get("sensor.dorm_2_humidity") - assert state.state == "40" - - state = hass.states.get("sensor.dorm_ppal_temperature") - assert state.state == "21.1" - - state = hass.states.get("sensor.dorm_ppal_humidity") - assert state.state == "39" - - state = hass.states.get("sensor.salon_temperature") - assert state.state == "19.6" - - state = hass.states.get("sensor.salon_humidity") - assert state.state == "34" - - state = hass.states.get("sensor.airzone_2_1_temperature") - assert state.state == "22.3" - - state = hass.states.get("sensor.airzone_2_1_humidity") - assert state.state == "62" - - state = hass.states.get("sensor.dkn_plus_temperature") - assert state.state == "21.7" + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) state = hass.states.get("sensor.dkn_plus_humidity") assert state is None diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 50d1964924d..55cb32b67a5 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -11,12 +11,14 @@ from aioairzone.const import ( API_ACS_SET_POINT, API_ACS_TEMP, API_AIR_DEMAND, + API_BATTERY, API_COLD_ANGLE, API_COLD_STAGE, API_COLD_STAGES, API_COOL_MAX_TEMP, API_COOL_MIN_TEMP, API_COOL_SET_POINT, + API_COVERAGE, API_DATA, API_ERRORS, API_FLOOR_DEMAND, @@ -119,6 +121,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 99, + API_COVERAGE: 72, API_ON: 1, API_MAX_TEMP: 30, API_MIN_TEMP: 15, @@ -147,6 +151,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 35, + API_COVERAGE: 60, API_ON: 1, API_MAX_TEMP: 30, API_MIN_TEMP: 15, @@ -173,6 +179,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 25, + API_COVERAGE: 88, API_ON: 0, API_MAX_TEMP: 86, API_MIN_TEMP: 59, @@ -203,6 +211,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 80, + API_COVERAGE: 66, API_ON: 0, API_MAX_TEMP: 30, API_MIN_TEMP: 15, @@ -361,7 +371,7 @@ HVAC_WEBSERVER_MOCK = { async def async_init_integration( hass: HomeAssistant, -) -> None: +) -> MockConfigEntry: """Set up the Airzone integration in Home Assistant.""" config_entry = MockConfigEntry( @@ -397,3 +407,5 @@ async def async_init_integration( ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index e76ed4ba6d0..c0f206ee4e2 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -1,6 +1,5 @@ """The tests for the Alexa component.""" -from asyncio import AbstractEventLoop import datetime from http import HTTPStatus @@ -24,13 +23,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client( - event_loop: AbstractEventLoop, +async def alexa_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Initialize a Home Assistant server for testing this module.""" - loop = event_loop @callback def mock_service(call): @@ -38,38 +35,36 @@ def alexa_client( hass.services.async_register("test", "alexa", mock_service) - assert loop.run_until_complete( - async_setup_component( - hass, - alexa.DOMAIN, - { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "alexa": { - "flash_briefings": { - "password": "pass/abc", - "weather": [ - { - "title": "Weekly forecast", - "text": "This week it will be sunny.", - }, - { - "title": "Current conditions", - "text": "Currently it is 80 degrees fahrenheit.", - }, - ], - "news_audio": { - "title": "NPR", - "audio": NPR_NEWS_MP3_URL, - "display_url": "https://npr.org", - "uid": "uuid", + assert await async_setup_component( + hass, + alexa.DOMAIN, + { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": { + "flash_briefings": { + "password": "pass/abc", + "weather": [ + { + "title": "Weekly forecast", + "text": "This week it will be sunny.", }, - } - }, + { + "title": "Current conditions", + "text": "Currently it is 80 degrees fahrenheit.", + }, + ], + "news_audio": { + "title": "NPR", + "audio": NPR_NEWS_MP3_URL, + "display_url": "https://npr.org", + "uid": "uuid", + }, + } }, - ) + }, ) - return loop.run_until_complete(hass_client()) + return await hass_client() def _flash_briefing_req(client, briefing_id, password="pass%2Fabc"): diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index b82048dca9b..9c9a292c456 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -1,6 +1,5 @@ """The tests for the Alexa component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json @@ -30,13 +29,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client( - event_loop: AbstractEventLoop, +async def alexa_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Initialize a Home Assistant server for testing this module.""" - loop = event_loop @callback def mock_service(call): @@ -44,96 +41,92 @@ def alexa_client( hass.services.async_register("test", "alexa", mock_service) - assert loop.run_until_complete( - async_setup_component( - hass, - alexa.DOMAIN, - { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "alexa": {}, - }, - ) + assert await async_setup_component( + hass, + alexa.DOMAIN, + { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": {}, + }, ) - assert loop.run_until_complete( - async_setup_component( - hass, - "intent_script", - { - "intent_script": { - "WhereAreWeIntent": { - "speech": { - "type": "plain", - "text": """ - {%- if is_state("device_tracker.paulus", "home") - and is_state("device_tracker.anne_therese", - "home") -%} - You are both home, you silly - {%- else -%} - Anne Therese is at {{ - states("device_tracker.anne_therese") - }} and Paulus is at {{ - states("device_tracker.paulus") - }} - {% endif %} - """, - } + assert await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "WhereAreWeIntent": { + "speech": { + "type": "plain", + "text": """ + {%- if is_state("device_tracker.paulus", "home") + and is_state("device_tracker.anne_therese", + "home") -%} + You are both home, you silly + {%- else -%} + Anne Therese is at {{ + states("device_tracker.anne_therese") + }} and Paulus is at {{ + states("device_tracker.paulus") + }} + {% endif %} + """, + } + }, + "GetZodiacHoroscopeIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign }}.", + } + }, + "GetZodiacHoroscopeIDIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign_Id }}.", + } + }, + "AMAZON.PlaybackAction": { + "speech": { + "type": "plain", + "text": "Playing {{ object_byArtist_name }}.", + } + }, + "CallServiceIntent": { + "speech": { + "type": "plain", + "text": "Service called for {{ ZodiacSign }}", }, - "GetZodiacHoroscopeIntent": { - "speech": { - "type": "plain", - "text": "You told us your sign is {{ ZodiacSign }}.", - } + "card": { + "type": "simple", + "title": "Card title for {{ ZodiacSign }}", + "content": "Card content: {{ ZodiacSign }}", }, - "GetZodiacHoroscopeIDIntent": { - "speech": { - "type": "plain", - "text": "You told us your sign is {{ ZodiacSign_Id }}.", - } + "action": { + "service": "test.alexa", + "data_template": {"hello": "{{ ZodiacSign }}"}, + "entity_id": "switch.test", }, - "AMAZON.PlaybackAction": { - "speech": { - "type": "plain", - "text": "Playing {{ object_byArtist_name }}.", - } + }, + APPLICATION_ID: { + "speech": { + "type": "plain", + "text": "LaunchRequest has been received.", + } + }, + APPLICATION_ID_SESSION_OPEN: { + "speech": { + "type": "plain", + "text": "LaunchRequest has been received.", }, - "CallServiceIntent": { - "speech": { - "type": "plain", - "text": "Service called for {{ ZodiacSign }}", - }, - "card": { - "type": "simple", - "title": "Card title for {{ ZodiacSign }}", - "content": "Card content: {{ ZodiacSign }}", - }, - "action": { - "service": "test.alexa", - "data_template": {"hello": "{{ ZodiacSign }}"}, - "entity_id": "switch.test", - }, + "reprompt": { + "type": "plain", + "text": "LaunchRequest has been received.", }, - APPLICATION_ID: { - "speech": { - "type": "plain", - "text": "LaunchRequest has been received.", - } - }, - APPLICATION_ID_SESSION_OPEN: { - "speech": { - "type": "plain", - "text": "LaunchRequest has been received.", - }, - "reprompt": { - "type": "plain", - "text": "LaunchRequest has been received.", - }, - }, - } - }, - ) + }, + } + }, ) - return loop.run_until_complete(hass_client()) + return await hass_client() def _intent_req(client, data=None): diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr index 8637471cc60..ddf05c99b88 100644 --- a/tests/components/ambient_network/snapshots/test_sensor.ambr +++ b/tests/components/ambient_network/snapshots/test_sensor.ambr @@ -815,7 +815,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -854,6 +856,7 @@ 'device_class': 'wind_direction', 'friendly_name': 'Station A Wind direction', 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -1800,7 +1803,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1839,6 +1844,7 @@ 'device_class': 'wind_direction', 'friendly_name': 'Station C Wind direction', 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -2722,7 +2728,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2760,6 +2768,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'wind_direction', 'friendly_name': 'Station D Wind direction', + 'state_class': , 'unit_of_measurement': '°', }), 'context': , diff --git a/tests/components/analytics_insights/fixtures/current_data.json b/tests/components/analytics_insights/fixtures/current_data.json index c652a8c0154..ff1baca49ed 100644 --- a/tests/components/analytics_insights/fixtures/current_data.json +++ b/tests/components/analytics_insights/fixtures/current_data.json @@ -1050,7 +1050,6 @@ "melnor": 42, "plaato": 45, "freedompro": 26, - "sunweg": 3, "logi_circle": 18, "proxy": 16, "statsd": 4, diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index c0ed986f002..ea4ce5a980d 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -3,11 +3,11 @@ list([ dict({ 'content': ''' - Current time is 16:00:00. Today's date is 2024-06-03. You are a voice assistant for Home Assistant. Answer questions about the world truthfully. Answer in plain text. Keep it simple and to the point. Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + Current time is 16:00:00. Today's date is 2024-06-03. ''', 'role': 'system', }), diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 6363304effc..26a3d7c7a8c 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -22,12 +22,12 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def mock_api_client( +async def mock_api_client( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> TestClient: """Start the Home Assistant HTTP component and return admin API client.""" - hass.loop.run_until_complete(async_setup_component(hass, "api", {})) - return hass.loop.run_until_complete(hass_client()) + await async_setup_component(hass, "api", {}) + return await hass_client() async def test_api_list_state_entities( diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index a13eb3c605b..c9d698e068b 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -59,12 +59,12 @@ def use_mocked_zeroconf(mock_async_zeroconf: MagicMock) -> None: @pytest.fixture(autouse=True) -def mock_setup_entry() -> Generator[None]: +def mock_setup_entry() -> Generator[Mock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.apple_tv.async_setup_entry", return_value=True - ): - yield + ) as setup_entry: + yield setup_entry # User Flows @@ -1183,7 +1183,9 @@ async def test_zeroconf_mismatch(hass: HomeAssistant, mock_scan: AsyncMock) -> N @pytest.mark.usefixtures("mrp_device", "pairing") -async def test_reconfigure_update_credentials(hass: HomeAssistant) -> None: +async def test_reconfigure_update_credentials( + hass: HomeAssistant, mock_setup_entry: Mock +) -> None: """Test that reconfigure flow updates config entry.""" config_entry = MockConfigEntry( domain="apple_tv", unique_id="mrpid", data={"identifiers": ["mrpid"]} @@ -1215,6 +1217,9 @@ async def test_reconfigure_update_credentials(hass: HomeAssistant) -> None: "identifiers": ["mrpid"], } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + # Options diff --git a/tests/components/apsystems/conftest.py b/tests/components/apsystems/conftest.py index 92af6885c0b..d1c97e991a8 100644 --- a/tests/components/apsystems/conftest.py +++ b/tests/components/apsystems/conftest.py @@ -43,6 +43,7 @@ def mock_apsystems() -> Generator[MagicMock]: ipAddr="127.0.01", minPower=0, maxPower=1000, + isBatterySystem=False, ) mock_api.get_output_data.return_value = ReturnOutputData( p1=2.0, diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index a7f6fbf7553..d67a0fd1726 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -684,7 +684,7 @@ def test_fallback_intent_filter() -> None: entities_list=[], ) ) - is True + is False ) assert ( _async_local_fallback_intent_filter( diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 6604fdc3f25..8050b23f5ff 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -22,6 +22,7 @@ from homeassistant.components.assist_satellite import ( AssistSatelliteAnnouncement, SatelliteBusyError, ) +from homeassistant.components.assist_satellite.const import PREANNOUNCE_URL from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry @@ -185,7 +186,7 @@ async def test_new_pipeline_cancels_pipeline( ("service_data", "expected_params"), [ ( - {"message": "Hello"}, + {"message": "Hello", "preannounce": False}, AssistSatelliteAnnouncement( message="Hello", media_id="http://10.10.10.10:8123/api/tts_proxy/test-token", @@ -198,6 +199,7 @@ async def test_new_pipeline_cancels_pipeline( { "message": "Hello", "media_id": "media-source://given", + "preannounce": False, }, AssistSatelliteAnnouncement( message="Hello", @@ -208,7 +210,7 @@ async def test_new_pipeline_cancels_pipeline( ), ), ( - {"media_id": "http://example.com/bla.mp3"}, + {"media_id": "http://example.com/bla.mp3", "preannounce": False}, AssistSatelliteAnnouncement( message="", media_id="http://example.com/bla.mp3", @@ -217,6 +219,20 @@ async def test_new_pipeline_cancels_pipeline( media_id_source="url", ), ), + ( + { + "media_id": "http://example.com/bla.mp3", + "preannounce_media_id": "http://example.com/preannounce.mp3", + }, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/bla.mp3", + original_media_id="http://example.com/bla.mp3", + tts_token=None, + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), ], ) async def test_announce( @@ -354,6 +370,24 @@ async def test_announce_cancels_pipeline( mock_async_announce.assert_called_once() +async def test_announce_default_preannounce( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test announcing on a device with the default preannouncement sound.""" + + async def async_announce(announcement): + assert announcement.preannounce_media_id.endswith(PREANNOUNCE_URL) + + with patch.object(entity, "async_announce", new=async_announce): + await hass.services.async_call( + "assist_satellite", + "announce", + {"media_id": "test-media-id"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + async def test_context_refresh( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: @@ -507,6 +541,7 @@ async def test_vad_sensitivity_entity_not_found( { "start_message": "Hello", "extra_system_prompt": "Better system prompt", + "preannounce": False, }, ( "mock-conversation-id", @@ -524,6 +559,7 @@ async def test_vad_sensitivity_entity_not_found( { "start_message": "Hello", "start_media_id": "media-source://given", + "preannounce": False, }, ( "mock-conversation-id", @@ -538,7 +574,10 @@ async def test_vad_sensitivity_entity_not_found( ), ), ( - {"start_media_id": "http://example.com/given.mp3"}, + { + "start_media_id": "http://example.com/given.mp3", + "preannounce": False, + }, ( "mock-conversation-id", None, @@ -551,6 +590,24 @@ async def test_vad_sensitivity_entity_not_found( ), ), ), + ( + { + "start_media_id": "http://example.com/given.mp3", + "preannounce_media_id": "http://example.com/preannounce.mp3", + }, + ( + "mock-conversation-id", + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + tts_token=None, + original_media_id="http://example.com/given.mp3", + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), + ), ], ) @pytest.mark.usefixtures("mock_chat_session_conversation_id") @@ -562,6 +619,13 @@ async def test_start_conversation( expected_params: tuple[str, str], ) -> None: """Test starting a conversation on a device.""" + original_start_conversation = entity.async_start_conversation + + async def async_start_conversation(start_announcement): + # Verify state change + assert entity.state == AssistSatelliteState.RESPONDING + await original_start_conversation(start_announcement) + await async_update_pipeline( hass, async_get_pipeline(hass), @@ -588,6 +652,7 @@ async def test_start_conversation( mime_type="audio/mp3", ), ), + patch.object(entity, "async_start_conversation", new=async_start_conversation), ): await hass.services.async_call( "assist_satellite", @@ -596,6 +661,7 @@ async def test_start_conversation( target={"entity_id": "assist_satellite.test_entity"}, blocking=True, ) + assert entity.state == AssistSatelliteState.IDLE assert entity.start_conversations[0] == expected_params @@ -616,6 +682,32 @@ async def test_start_conversation_reject_builtin_agent( ) +async def test_start_conversation_default_preannounce( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test starting a conversation on a device with the default preannouncement sound.""" + + async def async_start_conversation(start_announcement): + assert PREANNOUNCE_URL in start_announcement.preannounce_media_id + + await async_update_pipeline( + hass, + async_get_pipeline(hass), + conversation_engine="conversation.some_llm", + ) + + with ( + patch.object(entity, "async_start_conversation", new=async_start_conversation), + ): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + {"start_media_id": "test-media-id"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + async def test_wake_word_start_keeps_responding( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py index f0a8f02fc50..23eec7e8461 100644 --- a/tests/components/assist_satellite/test_websocket_api.py +++ b/tests/components/assist_satellite/test_websocket_api.py @@ -445,6 +445,7 @@ async def test_connection_test( assert len(entity.announcements) == 1 assert entity.announcements[0].message == "" + assert entity.announcements[0].preannounce_media_id is None announcement_media_id = entity.announcements[0].media_id hass_url = "http://10.10.10.10:8123" assert announcement_media_id.startswith( diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index 8c9cd6e3a24..040deaf8f80 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -127,7 +127,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, entity_registry, - "sensor.living_room_vocs", + "sensor.living_room_volatile_organic_compounds_parts", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_VOC].unique_id_tag}", "366", { diff --git a/tests/components/azure_devops/snapshots/test_sensor.ambr b/tests/components/azure_devops/snapshots/test_sensor.ambr index 0b8f35497c6..3fe4d470a63 100644 --- a/tests/components/azure_devops/snapshots/test_sensor.ambr +++ b/tests/components/azure_devops/snapshots/test_sensor.ambr @@ -131,7 +131,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CI latest build id', + 'original_name': 'CI latest build ID', 'platform': 'azure_devops', 'previous_unique_id': None, 'supported_features': 0, @@ -143,7 +143,7 @@ # name: test_sensors[sensor.testproject_ci_latest_build_id-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build id', + 'friendly_name': 'testproject CI latest build ID', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_id', @@ -462,7 +462,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CI latest build url', + 'original_name': 'CI latest build URL', 'platform': 'azure_devops', 'previous_unique_id': None, 'supported_features': 0, @@ -474,7 +474,7 @@ # name: test_sensors[sensor.testproject_ci_latest_build_url-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build url', + 'friendly_name': 'testproject CI latest build URL', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_url', @@ -526,7 +526,7 @@ # name: test_sensors_missing_data[sensor.testproject_ci_latest_build_id-state-missing-data] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build id', + 'friendly_name': 'testproject CI latest build ID', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_id', @@ -619,7 +619,7 @@ # name: test_sensors_missing_data[sensor.testproject_ci_latest_build_url-state-missing-data] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build url', + 'friendly_name': 'testproject CI latest build URL', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_url', diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index e6e4b2f8a50..3197cbfadeb 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Coroutine, Iterable +from collections.abc import AsyncIterator, Buffer, Callable, Coroutine, Iterable from pathlib import Path -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, Mock, patch from homeassistant.components.backup import ( @@ -16,6 +16,7 @@ from homeassistant.components.backup import ( BackupNotFound, Folder, ) +from homeassistant.components.backup.backup import CoreLocalBackupAgent from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup @@ -69,7 +70,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo async def delete_backup(backup_id: str, **kwargs: Any) -> None: """Mock delete.""" - get_backup(backup_id) + await get_backup(backup_id) async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: """Mock download.""" @@ -77,7 +78,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup: """Get a backup.""" - backup = next((b for b in backups if b.backup_id == backup_id), None) + backup = next((b for b in _backups if b.backup_id == backup_id), None) if backup is None: raise BackupNotFound return backup @@ -89,15 +90,15 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo **kwargs: Any, ) -> None: """Upload a backup.""" - backups.append(backup) + _backups.append(backup) backup_stream = await open_stream() backup_data = bytearray() async for chunk in backup_stream: backup_data += chunk backups_data[backup.backup_id] = backup_data - backups = backups or [] - backups_data: dict[str, bytes] = {} + _backups = backups or [] + backups_data: dict[str, Buffer] = {} mock_agent = Mock(spec=BackupAgent) mock_agent.domain = TEST_DOMAIN mock_agent.name = name @@ -113,7 +114,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo side_effect=get_backup, spec_set=[BackupAgent.async_get_backup] ) mock_agent.async_list_backups = AsyncMock( - return_value=backups, spec_set=[BackupAgent.async_list_backups] + return_value=_backups, spec_set=[BackupAgent.async_list_backups] ) mock_agent.async_upload_backup = AsyncMock( side_effect=upload_backup, @@ -160,11 +161,18 @@ async def setup_backup_integration( if LOCAL_AGENT_ID not in backups or with_hassio: return remote_agents_dict - agent = hass.data[DATA_MANAGER].backup_agents[LOCAL_AGENT_ID] + local_agent = cast( + CoreLocalBackupAgent, hass.data[DATA_MANAGER].backup_agents[LOCAL_AGENT_ID] + ) for backup in backups[LOCAL_AGENT_ID]: - await agent.async_upload_backup(open_stream=None, backup=backup) - agent._loaded_backups = True + await local_agent.async_upload_backup( + open_stream=AsyncMock( + side_effect=RuntimeError("Local agent does not open stream") + ), + backup=backup, + ) + local_agent._loaded_backups = True return remote_agents_dict diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index eb38399eb79..d391df44475 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -61,24 +61,49 @@ def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]: CONFIG_DIR = { - "testing_config": [ + "tests/testing_config": [ Path("test.txt"), Path(".DS_Store"), Path(".storage"), + Path("another_subdir"), Path("backups"), Path("tmp_backups"), + Path("tts"), Path("home-assistant_v2.db"), ], - "backups": [ + "/backups": [ Path("backups/backup.tar"), Path("backups/not_backup"), ], - "tmp_backups": [ + "/another_subdir": [ + Path("another_subdir/.DS_Store"), + Path("another_subdir/backups"), + Path("another_subdir/tts"), + ], + "another_subdir/backups": [ + Path("another_subdir/backups/backup.tar"), + Path("another_subdir/backups/not_backup"), + ], + "another_subdir/tts": [ + Path("another_subdir/tts/voice.mp3"), + ], + "/tmp_backups": [ # noqa: S108 Path("tmp_backups/forgotten_backup.tar"), Path("tmp_backups/not_backup"), ], + "/tts": [ + Path("tts/voice.mp3"), + ], +} +CONFIG_DIR_DIRS = { + Path(".storage"), + Path("another_subdir"), + Path("another_subdir/backups"), + Path("another_subdir/tts"), + Path("backups"), + Path("tmp_backups"), + Path("tts"), } -CONFIG_DIR_DIRS = {Path(".storage"), Path("backups"), Path("tmp_backups")} @pytest.fixture(name="create_backup") @@ -105,7 +130,10 @@ def mock_backup_generation_fixture( """Mock backup generator.""" with ( - patch("pathlib.Path.iterdir", lambda x: CONFIG_DIR.get(x.name, [])), + patch( + "pathlib.Path.iterdir", + lambda x: CONFIG_DIR.get(f"{x.parent.name}/{x.name}", []), + ), patch("pathlib.Path.stat", return_value=MagicMock(st_size=123)), patch("pathlib.Path.is_file", lambda x: x not in CONFIG_DIR_DIRS), patch("pathlib.Path.is_dir", lambda x: x in CONFIG_DIR_DIRS), diff --git a/tests/components/backup/snapshots/test_diagnostics.ambr b/tests/components/backup/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..cf412970204 --- /dev/null +++ b/tests/components/backup/snapshots/test_diagnostics.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'backup_agents': list([ + dict({ + 'agent_id': 'backup.local', + 'name': 'local', + }), + ]), + 'backup_config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }) +# --- diff --git a/tests/components/backup/snapshots/test_sensors.ambr b/tests/components/backup/snapshots/test_sensors.ambr new file mode 100644 index 00000000000..be12afdbf1e --- /dev/null +++ b/tests/components/backup/snapshots/test_sensors.ambr @@ -0,0 +1,160 @@ +# serializer version: 1 +# name: test_sensors[sensor.backup_backup_manager_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'idle', + 'create_backup', + 'blocked', + 'receive_backup', + 'restore_backup', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.backup_backup_manager_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Backup Manager state', + 'platform': 'backup', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'backup_manager_state', + 'unique_id': 'backup_manager_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.backup_backup_manager_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Backup Backup Manager state', + 'options': list([ + 'idle', + 'create_backup', + 'blocked', + 'receive_backup', + 'restore_backup', + ]), + }), + 'context': , + 'entity_id': 'sensor.backup_backup_manager_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensors[sensor.backup_last_successful_automatic_backup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.backup_last_successful_automatic_backup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last successful automatic backup', + 'platform': 'backup', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_successful_automatic_backup', + 'unique_id': 'last_successful_automatic_backup', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.backup_last_successful_automatic_backup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Backup Last successful automatic backup', + }), + 'context': , + 'entity_id': 'sensor.backup_last_successful_automatic_backup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.backup_next_scheduled_automatic_backup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.backup_next_scheduled_automatic_backup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next scheduled automatic backup', + 'platform': 'backup', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_scheduled_automatic_backup', + 'unique_id': 'next_scheduled_automatic_backup', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.backup_next_scheduled_automatic_backup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Backup Next scheduled automatic backup', + }), + 'context': , + 'entity_id': 'sensor.backup_next_scheduled_automatic_backup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/backup/test_diagnostics.py b/tests/components/backup/test_diagnostics.py new file mode 100644 index 00000000000..a66b4a9a2ea --- /dev/null +++ b/tests/components/backup/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests the diagnostics for Home Assistant Backup integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.components.backup.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .common import setup_backup_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_backup_integration(hass, with_hassio=False) + await hass.async_block_till_done(wait_background_tasks=True) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + diag_data = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert diag_data == snapshot diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index 8a0cc2b97c0..10bd2d8b97a 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -6,11 +6,13 @@ from unittest.mock import patch import pytest from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN +from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotFound from .common import setup_backup_integration +from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -141,3 +143,17 @@ async def test_create_automatic_service( ) generate_backup.assert_called_once_with(**expected_kwargs) + + +async def test_setup_entry( + hass: HomeAssistant, +) -> None: + """Test setup backup config entry.""" + await setup_backup_integration(hass, with_hassio=False) + entry = MockConfigEntry(domain=DOMAIN, source=SOURCE_SYSTEM) + entry.add_to_hass(hass) + + with patch("homeassistant.components.backup.PLATFORMS", return_value=[]): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index fef4b84ac61..04072dae864 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -68,10 +68,17 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator _EXPECTED_FILES = [ "test.txt", ".storage", + "another_subdir", + "another_subdir/backups", + "another_subdir/backups/backup.tar", + "another_subdir/backups/not_backup", + "another_subdir/tts", + "another_subdir/tts/voice.mp3", "backups", "backups/not_backup", "tmp_backups", "tmp_backups/not_backup", + "tts", ] _EXPECTED_FILES_WITH_DATABASE = { True: [*_EXPECTED_FILES, "home-assistant_v2.db"], diff --git a/tests/components/backup/test_sensors.py b/tests/components/backup/test_sensors.py new file mode 100644 index 00000000000..bee61887ea5 --- /dev/null +++ b/tests/components/backup/test_sensors.py @@ -0,0 +1,119 @@ +"""Tests for the sensors of the Backup integration.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.backup import store +from homeassistant.components.backup.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import setup_backup_integration + +from tests.common import async_fire_time_changed, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_sensors( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of backup sensors.""" + with patch("homeassistant.components.backup.PLATFORMS", [Platform.SENSOR]): + await setup_backup_integration(hass, with_hassio=False) + await hass.async_block_till_done(wait_background_tasks=True) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + # start backup and check sensor states again + client = await hass_ws_client(hass) + await hass.async_block_till_done() + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["backup.local"]} + ) + + assert await client.receive_json() + state = hass.states.get("sensor.backup_backup_manager_state") + assert state.state == "create_backup" + + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("sensor.backup_backup_manager_state") + assert state.state == "idle" + + +async def test_sensor_updates( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], + create_backup: AsyncMock, +) -> None: + """Test update of backup sensors.""" + # Ensure created backup is already protected, + # to avoid manager creating a new EncryptedBackupStreamer + # instead of using the already mocked stream writer. + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-12T12:00:00+01:00") + storage_data = { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["test.remote"], + "include_addons": [], + "include_all_addons": False, + "include_database": True, + "include_folders": [], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": "2024-11-11T04:45:00+01:00", + "last_completed_automatic_backup": "2024-11-11T04:45:00+01:00", + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": "06:00", + }, + }, + } + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + } + + with patch("homeassistant.components.backup.PLATFORMS", [Platform.SENSOR]): + await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] + ) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.backup_last_successful_automatic_backup") + assert state.state == "2024-11-11T03:45:00+00:00" + state = hass.states.get("sensor.backup_next_scheduled_automatic_backup") + assert state.state == "2024-11-13T05:00:00+00:00" + + freezer.move_to("2024-11-13T12:00:00+01:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.backup_last_successful_automatic_backup") + assert state.state == "2024-11-13T11:00:00+00:00" + state = hass.states.get("sensor.backup_next_scheduled_automatic_backup") + assert state.state == "2024-11-14T05:00:00+00:00" diff --git a/tests/components/bluesound/conftest.py b/tests/components/bluesound/conftest.py index 717c9f61850..63597ed0532 100644 --- a/tests/components/bluesound/conftest.py +++ b/tests/components/bluesound/conftest.py @@ -102,8 +102,8 @@ class PlayerMockData: ) player.presets = AsyncMock( return_value=[ - Preset("preset1", "1", "url1", "image1", None), - Preset("preset2", "2", "url2", "image2", None), + Preset("preset1", 1, "url1", "image1", None), + Preset("preset2", 2, "url2", "image2", None), ] ) diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index ed537d0bc57..dcff33399f5 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -17,12 +17,14 @@ from homeassistant.components.bluesound.media_player import ( SERVICE_SET_TIMER, ) from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_SELECT_SOURCE, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, @@ -119,6 +121,32 @@ async def test_volume_down( player_mocks.player_data.player.volume.assert_called_once_with(level=9) +async def test_select_input_source( + hass: HomeAssistant, setup_config_entry: None, player_mocks: PlayerMocks +) -> None: + """Test the media player select input source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.player_name1111", ATTR_INPUT_SOURCE: "input1"}, + ) + + player_mocks.player_data.player.play_url.assert_called_once_with("url1") + + +async def test_select_preset_source( + hass: HomeAssistant, setup_config_entry: None, player_mocks: PlayerMocks +) -> None: + """Test the media player select preset source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.player_name1111", ATTR_INPUT_SOURCE: "preset1"}, + ) + + player_mocks.player_data.player.load_preset.assert_called_once_with(1) + + async def test_attributes_set( hass: HomeAssistant, setup_config_entry: None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index de76b07057e..0edead03f26 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -30,7 +30,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -42,7 +42,7 @@ # name: test_entity_state_attrs[select.i3_rex_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i3 (+ REX) Charging Mode', + 'friendly_name': 'i3 (+ REX) Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', @@ -98,7 +98,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Charging Limit', + 'original_name': 'AC charging limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -110,7 +110,7 @@ # name: test_entity_state_attrs[select.i4_edrive40_ac_charging_limit-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i4 eDrive40 AC Charging Limit', + 'friendly_name': 'i4 eDrive40 AC charging limit', 'options': list([ '6', '7', @@ -167,7 +167,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -179,7 +179,7 @@ # name: test_entity_state_attrs[select.i4_edrive40_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i4 eDrive40 Charging Mode', + 'friendly_name': 'i4 eDrive40 Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', @@ -235,7 +235,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Charging Limit', + 'original_name': 'AC charging limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -247,7 +247,7 @@ # name: test_entity_state_attrs[select.ix_xdrive50_ac_charging_limit-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'iX xDrive50 AC Charging Limit', + 'friendly_name': 'iX xDrive50 AC charging limit', 'options': list([ '6', '7', @@ -304,7 +304,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -316,7 +316,7 @@ # name: test_entity_state_attrs[select.ix_xdrive50_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'iX xDrive50 Charging Mode', + 'friendly_name': 'iX xDrive50 Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 73aece4af6b..e5139b253aa 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -15,6 +15,8 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( @@ -63,6 +65,59 @@ async def test_user_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_form_can_create_when_already_discovered( + hass: HomeAssistant, +) -> None: + """Test we get the user initiated form can create when already discovered.""" + + with patch_bond_version(), patch_bond_token(): + zc_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname="mock_hostname", + name="ZXXX12345.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), + ) + assert zc_result["type"] is FlowResultType.FORM + assert zc_result["errors"] == {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "ZXXX12345"}), + patch_bond_device_ids(return_value=["f6776c11", "f6776c12"]), + patch_bond_bridge(), + patch_bond_device_properties(), + patch_bond_device(), + patch_bond_device_state(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "some host", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "ZXXX12345" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_form_with_non_bridge(hass: HomeAssistant) -> None: """Test setup a smart by bond fan.""" @@ -97,6 +152,7 @@ async def test_user_form_with_non_bridge(hass: HomeAssistant) -> None: CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token", } + assert result2["result"].unique_id == "KXXX12345" assert len(mock_setup_entry.mock_calls) == 1 @@ -253,6 +309,107 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_dhcp_discovery(hass: HomeAssistant) -> None: + """Test DHCP discovery.""" + + with patch_bond_version(), patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ45842", + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_bridge(), + patch_bond_device_ids(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "KVPRBDJ45842" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_discovery_already_exists(hass: HomeAssistant) -> None: + """Test DHCP discovery for an already existing entry.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="KVPRBDJ45842", + ) + entry.add_to_hass(hass) + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_token(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ45842".lower(), + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_short_name(hass: HomeAssistant) -> None: + """Test DHCP discovery with the name cut off.""" + + with patch_bond_version(), patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ", + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_bridge(), + patch_bond_device_ids(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "KVPRBDJ45842" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: """Test we get the discovery form and we handle the token being unavailable.""" diff --git a/tests/components/bosch_alarm/__init__.py b/tests/components/bosch_alarm/__init__.py new file mode 100644 index 00000000000..2b2d94cf1e5 --- /dev/null +++ b/tests/components/bosch_alarm/__init__.py @@ -0,0 +1,22 @@ +"""Tests for the Bosch Alarm component.""" + +from unittest.mock import AsyncMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +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) + await hass.async_block_till_done() + + +async def call_observable(hass: HomeAssistant, observable: AsyncMock) -> None: + """Call the observable with the given event.""" + for callback in observable.attach.call_args_list: + callback[0][0]() + await hass.async_block_till_done() diff --git a/tests/components/bosch_alarm/conftest.py b/tests/components/bosch_alarm/conftest.py new file mode 100644 index 00000000000..8358624b003 --- /dev/null +++ b/tests/components/bosch_alarm/conftest.py @@ -0,0 +1,195 @@ +"""Define fixtures for Bosch Alarm tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from bosch_alarm_mode2.panel import Area, Door, Output, Point +from bosch_alarm_mode2.utils import Observable +import pytest + +from homeassistant.components.bosch_alarm.const import ( + CONF_INSTALLER_CODE, + CONF_USER_CODE, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PASSWORD, CONF_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture( + params=[ + "solution_3000", + "amax_3000", + "b5512", + ] +) +def model(request: pytest.FixtureRequest) -> Generator[str]: + """Return every device.""" + return request.param + + +@pytest.fixture +def extra_config_entry_data( + model: str, model_name: str, config_flow_data: dict[str, Any] +) -> dict[str, Any]: + """Return extra config entry data.""" + return {CONF_MODEL: model_name} | config_flow_data + + +@pytest.fixture +def config_flow_data(model: str) -> dict[str, Any]: + """Return extra config entry data.""" + if model == "solution_3000": + return {CONF_USER_CODE: "1234"} + if model == "amax_3000": + return {CONF_INSTALLER_CODE: "1234", CONF_PASSWORD: "1234567890"} + if model == "b5512": + return {CONF_PASSWORD: "1234567890"} + pytest.fail("Invalid model") + + +@pytest.fixture +def model_name(model: str) -> str | None: + """Return extra config entry data.""" + return { + "solution_3000": "Solution 3000", + "amax_3000": "AMAX 3000", + "b5512": "B5512 (US1B)", + }.get(model) + + +@pytest.fixture +def serial_number(model: str) -> str | None: + """Return extra config entry data.""" + if model == "solution_3000": + return "1234567890" + return None + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.bosch_alarm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def points() -> Generator[dict[int, Point]]: + """Define a mocked door.""" + names = [ + "Window", + "Door", + "Motion Detector", + "CO Detector", + "Smoke Detector", + "Glassbreak Sensor", + "Bedroom", + ] + points = {} + for i, name in enumerate(names): + mock = AsyncMock(spec=Point) + mock.name = name + mock.status_observer = AsyncMock(spec=Observable) + mock.is_open.return_value = False + mock.is_normal.return_value = True + points[i] = mock + return points + + +@pytest.fixture +def output() -> Generator[Output]: + """Define a mocked output.""" + mock = AsyncMock(spec=Output) + mock.name = "Output A" + mock.status_observer = AsyncMock(spec=Observable) + mock.is_active.return_value = False + return mock + + +@pytest.fixture +def door() -> Generator[Door]: + """Define a mocked door.""" + mock = AsyncMock(spec=Door) + mock.name = "Main Door" + mock.status_observer = AsyncMock(spec=Observable) + mock.is_open.return_value = False + mock.is_locked.return_value = True + return mock + + +@pytest.fixture +def area() -> Generator[Area]: + """Define a mocked area.""" + mock = AsyncMock(spec=Area) + mock.name = "Area1" + mock.status_observer = AsyncMock(spec=Observable) + mock.alarm_observer = AsyncMock(spec=Observable) + mock.ready_observer = AsyncMock(spec=Observable) + mock.alarms = [] + mock.faults = [] + mock.all_ready = True + mock.part_ready = True + mock.is_triggered.return_value = False + mock.is_disarmed.return_value = True + mock.is_armed.return_value = False + mock.is_arming.return_value = False + mock.is_pending.return_value = False + mock.is_part_armed.return_value = False + mock.is_all_armed.return_value = False + return mock + + +@pytest.fixture +def mock_panel( + area: AsyncMock, + door: AsyncMock, + output: AsyncMock, + points: dict[int, AsyncMock], + model_name: str, + serial_number: str | None, +) -> Generator[AsyncMock]: + """Define a fixture to set up Bosch Alarm.""" + with ( + patch( + "homeassistant.components.bosch_alarm.Panel", autospec=True + ) as mock_panel, + patch("homeassistant.components.bosch_alarm.config_flow.Panel", new=mock_panel), + ): + client = mock_panel.return_value + client.areas = {1: area} + client.doors = {1: door} + client.outputs = {1: output} + client.points = points + client.model = model_name + client.faults = [] + client.events = [] + client.firmware_version = "1.0.0" + client.protocol_version = "1.0.0" + client.serial_number = serial_number + client.connection_status_observer = AsyncMock(spec=Observable) + client.faults_observer = AsyncMock(spec=Observable) + client.history_observer = AsyncMock(spec=Observable) + yield client + + +@pytest.fixture +def mock_config_entry( + extra_config_entry_data: dict[str, Any], serial_number: str | None +) -> MockConfigEntry: + """Mock config entry for bosch alarm.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=serial_number, + entry_id="01JQ917ACKQ33HHM7YCFXYZX51", + data={ + CONF_HOST: "0.0.0.0", + CONF_PORT: 7700, + CONF_MODEL: "bosch_alarm_test_data.model", + } + | extra_config_entry_data, + ) diff --git a/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr b/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..76568cef56c --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,154 @@ +# serializer version: 1 +# name: test_alarm_control_panel[amax_3000][alarm_control_panel.area1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.area1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[amax_3000][alarm_control_panel.area1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Area1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.area1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- +# name: test_alarm_control_panel[b5512][alarm_control_panel.area1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.area1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[b5512][alarm_control_panel.area1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Area1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.area1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- +# name: test_alarm_control_panel[solution_3000][alarm_control_panel.area1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.area1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1234567890_area_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[solution_3000][alarm_control_panel.area1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Area1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.area1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- diff --git a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..23ea722325f --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr @@ -0,0 +1,290 @@ +# serializer version: 1 +# name: test_diagnostics[amax_3000] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': list([ + ]), + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'AMAX 3000', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': None, + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'installer_code': '**REDACTED**', + 'model': 'AMAX 3000', + 'password': '**REDACTED**', + 'port': 7700, + }), + }) +# --- +# name: test_diagnostics[b5512] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': list([ + ]), + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'B5512 (US1B)', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': None, + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'model': 'B5512 (US1B)', + 'password': '**REDACTED**', + 'port': 7700, + }), + }) +# --- +# name: test_diagnostics[solution_3000] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': list([ + ]), + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'Solution 3000', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': '1234567890', + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'model': 'Solution 3000', + 'port': 7700, + 'user_code': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/bosch_alarm/test_alarm_control_panel.py b/tests/components/bosch_alarm/test_alarm_control_panel.py new file mode 100644 index 00000000000..31d2f928ec5 --- /dev/null +++ b/tests/components/bosch_alarm/test_alarm_control_panel.py @@ -0,0 +1,145 @@ +"""Tests for Bosch Alarm component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, + AlarmControlPanelState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_DISARM, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_observable, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch( + "homeassistant.components.bosch_alarm.PLATFORMS", [Platform.ALARM_CONTROL_PANEL] + ): + yield + + +async def test_update_alarm_device( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that alarm panel state changes after arming the panel.""" + await setup_integration(hass, mock_config_entry) + entity_id = "alarm_control_panel.area1" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + + area.is_arming.return_value = True + area.is_disarmed.return_value = False + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING + + area.is_arming.return_value = False + area.is_all_armed.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_AWAY + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + area.is_all_armed.return_value = False + area.is_disarmed.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_HOME, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + area.is_disarmed.return_value = False + area.is_arming.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING + + area.is_arming.return_value = False + area.is_part_armed.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_HOME + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + area.is_part_armed.return_value = False + area.is_disarmed.return_value = True + + await call_observable(hass, area.status_observer) + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + + +async def test_alarm_control_panel( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the alarm_control_panel state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_alarm_control_panel_availability( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the alarm_control_panel availability.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("alarm_control_panel.area1").state + == AlarmControlPanelState.DISARMED + ) + + mock_panel.connection_status.return_value = False + + await call_observable(hass, mock_panel.connection_status_observer) + + assert hass.states.get("alarm_control_panel.area1").state == STATE_UNAVAILABLE diff --git a/tests/components/bosch_alarm/test_config_flow.py b/tests/components/bosch_alarm/test_config_flow.py new file mode 100644 index 00000000000..4a1c9dad3ea --- /dev/null +++ b/tests/components/bosch_alarm/test_config_flow.py @@ -0,0 +1,285 @@ +"""Tests for the bosch_alarm config flow.""" + +import asyncio +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.bosch_alarm.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_form_user( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test the config flow for bosch_alarm.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config_flow_data, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Bosch {model_name}" + assert ( + result["data"] + == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 7700, + CONF_MODEL: model_name, + } + | config_flow_data + ) + assert result["result"].unique_id == serial_number + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (asyncio.TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test we handle exceptions correctly.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + mock_panel.connect.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": message} + + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config_flow_data, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (PermissionError, "invalid_auth"), + (asyncio.TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions_user( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test we handle exceptions correctly.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + mock_panel.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config_flow_data + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": message} + + mock_panel.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config_flow_data + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize("model", ["solution_3000", "amax_3000"]) +async def test_entry_already_configured_host( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], +) -> None: + """Test if configuring an entity twice results in an error.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "0.0.0.0"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config_flow_data + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("model", ["b5512"]) +async def test_entry_already_configured_serial( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], +) -> None: + """Test if configuring an entity twice results in an error.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "0.0.0.0"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config_flow_data + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + # Now check it works when there are no errors + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (OSError(), "cannot_connect"), + (PermissionError(), "invalid_auth"), + (Exception(), "unknown"), + ], +) +async def test_reauth_flow_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + mock_panel.connect.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == message + + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data diff --git a/tests/components/bosch_alarm/test_diagnostics.py b/tests/components/bosch_alarm/test_diagnostics.py new file mode 100644 index 00000000000..3e10878bd07 --- /dev/null +++ b/tests/components/bosch_alarm/test_diagnostics.py @@ -0,0 +1,32 @@ +"""Test the Bosch Alarm diagnostics.""" + +from typing import Any +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_panel: AsyncMock, + area: AsyncMock, + model_name: str, + serial_number: str, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + config_flow_data: dict[str, Any], +) -> None: + """Test generating diagnostics for bosch alarm.""" + await setup_integration(hass, mock_config_entry) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + assert diag == snapshot diff --git a/tests/components/bosch_alarm/test_init.py b/tests/components/bosch_alarm/test_init.py new file mode 100644 index 00000000000..13e938bd711 --- /dev/null +++ b/tests/components/bosch_alarm/test_init.py @@ -0,0 +1,47 @@ +"""Tests for bosch alarm integration init.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def disable_platform_only(): + """Disable platforms to speed up tests.""" + with patch("homeassistant.components.bosch_alarm.PLATFORMS", []): + yield + + +@pytest.mark.parametrize("model", ["solution_3000"]) +@pytest.mark.parametrize("exception", [PermissionError()]) +async def test_incorrect_auth( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test errors with incorrect auth.""" + mock_panel.connect.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize("model", ["solution_3000"]) +@pytest.mark.parametrize("exception", [TimeoutError()]) +async def test_connection_error( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test errors with incorrect auth.""" + mock_panel.connect.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py index bb2ccd1aec4..ef7e911fbba 100644 --- a/tests/components/cambridge_audio/test_media_player.py +++ b/tests/components/cambridge_audio/test_media_player.py @@ -10,6 +10,7 @@ from aiostreammagic import ( import pytest from homeassistant.components.media_player import ( + ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_REPEAT, @@ -489,3 +490,41 @@ async def test_play_media_unknown_type( }, blocking=True, ) + + +@pytest.mark.parametrize( + ("source_id", "artist", "station", "display"), + [ + ("MEDIA_PLAYER", "Metallica", None, "Metallica"), + ("USB_AUDIO", "Iron Maiden", "Radio BOB!", "Iron Maiden"), + ("IR", "In Flames", "Radio BOB!", "In Flames"), + ("IR", None, "Radio BOB!", "Radio BOB!"), + ("IR", None, None, None), + ("MEDIA_PLAYER", None, "Radio BOB!", None), + ], +) +async def test_media_artist( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_config_entry: MockConfigEntry, + source_id: str, + artist: str, + station: str, + display: str, +) -> None: + """Test media player state.""" + await setup_integration(hass, mock_config_entry) + mock_stream_magic_client.play_state.metadata.artist = artist + mock_stream_magic_client.play_state.metadata.station = station + mock_stream_magic_client.state.source = source_id + + await mock_state_update(mock_stream_magic_client) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + if (artist is None and source_id != "IR") or ( + source_id == "IR" and station is None + ): + assert ATTR_MEDIA_ARTIST not in state.attributes + else: + assert state.attributes[ATTR_MEDIA_ARTIST] == display diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 2d594fd9345..0e118f251de 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -218,9 +218,9 @@ def mock_user_data() -> Generator[MagicMock]: @pytest.fixture -def mock_cloud_fixture(hass: HomeAssistant) -> CloudPreferences: +async def mock_cloud_fixture(hass: HomeAssistant) -> CloudPreferences: """Fixture for cloud component.""" - hass.loop.run_until_complete(mock_cloud(hass)) + await mock_cloud(hass) return mock_cloud_prefs(hass, {}) diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index dd6252c4d62..8399e69ab09 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -5,9 +5,9 @@ from io import StringIO from typing import Any from unittest.mock import ANY, Mock, PropertyMock, patch -from aiohttp import ClientError +from aiohttp import ClientError, ClientResponseError from hass_nabucasa import CloudError -from hass_nabucasa.api import CloudApiNonRetryableError +from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError from hass_nabucasa.files import FilesError, StorageType import pytest @@ -547,6 +547,120 @@ async def test_agents_upload_not_protected( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_not_subscribed( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + cloud: Mock, +) -> None: + """Test upload backup when cloud user is not subscribed.""" + cloud.subscription_expired = True + client = await hass_client() + backup_data = "test" + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=len(backup_data), + ) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO(backup_data)}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert cloud.files.upload.call_count == 0 + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_not_subscribed_midway( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + cloud: Mock, +) -> None: + """Test upload backup when cloud subscription expires during the call.""" + client = await hass_client() + backup_data = "test" + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=len(backup_data), + ) + + async def mock_upload(*args: Any, **kwargs: Any) -> None: + """Mock file upload.""" + cloud.subscription_expired = True + raise CloudApiError( + "Boom!", orig_exc=ClientResponseError(Mock(), Mock(), status=403) + ) + + cloud.files.upload.side_effect = mock_upload + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO(backup_data)}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert cloud.files.upload.call_count == 1 + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] + + @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_upload_wrong_size( hass: HomeAssistant, diff --git a/tests/components/comelit/conftest.py b/tests/components/comelit/conftest.py index d2d450ccb8d..1e5e85cd26e 100644 --- a/tests/components/comelit/conftest.py +++ b/tests/components/comelit/conftest.py @@ -1,5 +1,7 @@ """Configure tests for Comelit SimpleHome.""" +from copy import deepcopy + import pytest from homeassistant.components.comelit.const import ( @@ -47,10 +49,10 @@ def mock_serial_bridge() -> Generator[AsyncMock]: ), ): bridge = mock_comelit_serial_bridge.return_value - bridge.get_all_devices.return_value = BRIDGE_DEVICE_QUERY + bridge.get_all_devices.return_value = deepcopy(BRIDGE_DEVICE_QUERY) bridge.host = BRIDGE_HOST bridge.port = BRIDGE_PORT - bridge.pin = BRIDGE_PIN + bridge.device_pin = BRIDGE_PIN yield bridge @@ -65,6 +67,7 @@ def mock_serial_bridge_config_entry() -> Generator[MockConfigEntry]: CONF_PIN: BRIDGE_PIN, CONF_TYPE: BRIDGE, }, + entry_id="serial_bridge_config_entry_id", ) @@ -82,10 +85,10 @@ def mock_vedo() -> Generator[AsyncMock]: ), ): vedo = mock_comelit_vedo.return_value - vedo.get_all_areas_and_zones.return_value = VEDO_DEVICE_QUERY + vedo.get_all_areas_and_zones.return_value = deepcopy(VEDO_DEVICE_QUERY) vedo.host = VEDO_HOST vedo.port = VEDO_PORT - vedo.pin = VEDO_PIN + vedo.device_pin = VEDO_PIN vedo.type = VEDO yield vedo @@ -101,4 +104,5 @@ def mock_vedo_config_entry() -> Generator[MockConfigEntry]: CONF_PIN: VEDO_PIN, CONF_TYPE: VEDO, }, + entry_id="vedo_config_entry_id", ) diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index f353ec97628..d06e6cfd8cb 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -29,13 +29,30 @@ VEDO_PIN = 5678 FAKE_PIN = 0000 BRIDGE_DEVICE_QUERY = { - CLIMATE: {}, + CLIMATE: { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=[ + [221, 0, "U", "M", 50, 0, 0, "U"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + }, COVER: { 0: ComelitSerialBridgeObject( index=0, name="Cover0", status=0, - human_status="closed", + human_status="stopped", type="cover", val=0, protected=0, @@ -58,7 +75,20 @@ BRIDGE_DEVICE_QUERY = { power_unit=WATT, ) }, - OTHER: {}, + OTHER: { + 0: ComelitSerialBridgeObject( + index=0, + name="Switch0", + status=0, + human_status="off", + type="other", + val=0, + protected=0, + zone="Bathroom", + power=0.0, + power_unit=WATT, + ), + }, IRRIGATION: {}, SCENARIO: {}, } @@ -69,16 +99,16 @@ VEDO_DEVICE_QUERY = AlarmDataObject( index=0, name="Area0", p1=True, - p2=False, + p2=True, ready=False, - armed=False, + armed=0, alarm=False, alarm_memory=False, sabotage=False, anomaly=False, in_time=False, out_time=False, - human_status=AlarmAreaState.UNKNOWN, + human_status=AlarmAreaState.DISARMED, ) }, alarm_zones={ diff --git a/tests/components/comelit/snapshots/test_climate.ambr b/tests/components/comelit/snapshots/test_climate.ambr new file mode 100644 index 00000000000..e5201067ee1 --- /dev/null +++ b/tests/components/comelit/snapshots/test_climate.ambr @@ -0,0 +1,71 @@ +# serializer version: 1 +# name: test_all_entities[climate.climate0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 5, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.climate0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[climate.climate0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.1, + 'friendly_name': 'Climate0', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 5, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 5.0, + }), + 'context': , + 'entity_id': 'climate.climate0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_cover.ambr b/tests/components/comelit/snapshots/test_cover.ambr new file mode 100644 index 00000000000..17189344cd1 --- /dev/null +++ b/tests/components/comelit/snapshots/test_cover.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[cover.cover0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.cover0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[cover.cover0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'shutter', + 'friendly_name': 'Cover0', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index c4544f38f52..51ea646df9f 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -5,13 +5,50 @@ 'devices': list([ dict({ 'clima': list([ + dict({ + '0': dict({ + 'human_status': 'off', + 'name': 'Climate0', + 'power': 0.0, + 'power_unit': 'W', + 'protected': 0, + 'status': 0, + 'val': list([ + list([ + 221, + 0, + 'U', + 'M', + 50, + 0, + 0, + 'U', + ]), + list([ + 650, + 0, + 'O', + 'M', + 500, + 0, + 0, + 'N', + ]), + list([ + 0, + 0, + ]), + ]), + 'zone': 'Living room', + }), + }), ]), }), dict({ 'shutter': list([ dict({ '0': dict({ - 'human_status': 'closed', + 'human_status': 'stopped', 'name': 'Cover0', 'power': 0.0, 'power_unit': 'W', @@ -41,6 +78,18 @@ }), dict({ 'other': list([ + dict({ + '0': dict({ + 'human_status': 'off', + 'name': 'Switch0', + 'power': 0.0, + 'power_unit': 'W', + 'protected': 0, + 'status': 0, + 'val': 0, + 'zone': 'Bathroom', + }), + }), ]), }), dict({ @@ -92,13 +141,13 @@ 'alarm': False, 'alarm_memory': False, 'anomaly': False, - 'armed': False, - 'human_status': 'unknown', + 'armed': 0, + 'human_status': 'disarmed', 'in_time': False, 'name': 'Area0', 'out_time': False, 'p1': True, - 'p2': False, + 'p2': True, 'ready': False, 'sabotage': False, }), diff --git a/tests/components/comelit/snapshots/test_light.ambr b/tests/components/comelit/snapshots/test_light.ambr new file mode 100644 index 00000000000..c60c962e23d --- /dev/null +++ b/tests/components/comelit/snapshots/test_light.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_all_entities[light.light0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.light0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.light0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Light0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_sensor.ambr b/tests/components/comelit/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..dabae2a1bf0 --- /dev/null +++ b/tests/components/comelit/snapshots/test_sensor.ambr @@ -0,0 +1,76 @@ +# serializer version: 1 +# name: test_all_entities[sensor.zone0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'alarm', + 'armed', + 'open', + 'excluded', + 'faulty', + 'inhibited', + 'isolated', + 'rest', + 'sabotated', + 'unavailable', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zone0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'zone_status', + 'unique_id': 'vedo_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zone0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Zone0', + 'options': list([ + 'alarm', + 'armed', + 'open', + 'excluded', + 'faulty', + 'inhibited', + 'isolated', + 'rest', + 'sabotated', + 'unavailable', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.zone0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rest', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_switch.ambr b/tests/components/comelit/snapshots/test_switch.ambr new file mode 100644 index 00000000000..eddecfabb7a --- /dev/null +++ b/tests/components/comelit/snapshots/test_switch.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_all_entities[switch.switch0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.switch0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-other-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.switch0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Switch0', + }), + 'context': , + 'entity_id': 'switch.switch0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/comelit/test_alarm_control_panel.py b/tests/components/comelit/test_alarm_control_panel.py new file mode 100644 index 00000000000..d3feac6ad3b --- /dev/null +++ b/tests/components/comelit/test_alarm_control_panel.py @@ -0,0 +1,155 @@ +"""Tests for Comelit SimpleHome alarm control panel platform.""" + +from unittest.mock import AsyncMock + +from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject +from aiocomelit.const import AlarmAreaState, AlarmZoneState +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.alarm_control_panel import ( + ATTR_CODE, + DOMAIN as ALARM_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + AlarmControlPanelState, +) +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .const import VEDO_PIN + +from tests.common import MockConfigEntry, async_fire_time_changed + +ENTITY_ID = "alarm_control_panel.area0" + + +@pytest.mark.parametrize( + ("human_status", "armed", "alarm_state"), + [ + (AlarmAreaState.DISARMED, 0, AlarmControlPanelState.DISARMED), + (AlarmAreaState.ARMED, 1, AlarmControlPanelState.ARMED_HOME), + (AlarmAreaState.ARMED, 2, AlarmControlPanelState.ARMED_HOME), + (AlarmAreaState.ARMED, 3, AlarmControlPanelState.ARMED_NIGHT), + (AlarmAreaState.ARMED, 4, AlarmControlPanelState.ARMED_AWAY), + (AlarmAreaState.UNKNOWN, 0, STATE_UNAVAILABLE), + ], +) +async def test_entity_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + human_status: AlarmAreaState, + armed: int, + alarm_state: AlarmControlPanelState, +) -> None: + """Test all entities.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED + + vedo_query = AlarmDataObject( + alarm_areas={ + 0: ComelitVedoAreaObject( + index=0, + name="Area0", + p1=True, + p2=True, + ready=False, + armed=armed, + alarm=False, + alarm_memory=False, + sabotage=False, + anomaly=False, + in_time=False, + out_time=False, + human_status=human_status, + ) + }, + alarm_zones={ + 0: ComelitVedoZoneObject( + index=0, + name="Zone0", + status_api="0x000", + status=0, + human_status=AlarmZoneState.REST, + ) + }, + ) + + mock_vedo.get_all_areas_and_zones.return_value = vedo_query + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == alarm_state + + +@pytest.mark.parametrize( + ("service", "alarm_state"), + [ + (SERVICE_ALARM_DISARM, AlarmControlPanelState.DISARMED), + (SERVICE_ALARM_ARM_AWAY, AlarmControlPanelState.ARMED_AWAY), + (SERVICE_ALARM_ARM_HOME, AlarmControlPanelState.ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, AlarmControlPanelState.ARMED_NIGHT), + ], +) +async def test_arming_disarming( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + service: str, + alarm_state: AlarmControlPanelState, +) -> None: + """Test arming and disarming.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED + + await hass.services.async_call( + ALARM_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_CODE: VEDO_PIN}, + blocking=True, + ) + + mock_vedo.set_zone_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == alarm_state + + +async def test_wrong_code( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test disarm service with wrong code.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED + + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_CODE: 1111}, + blocking=True, + ) + + mock_vedo.set_zone_status.assert_not_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED diff --git a/tests/components/comelit/test_climate.py b/tests/components/comelit/test_climate.py new file mode 100644 index 00000000000..f9f28b4d675 --- /dev/null +++ b/tests/components/comelit/test_climate.py @@ -0,0 +1,282 @@ +"""Tests for Comelit SimpleHome climate platform.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE, WATT +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "climate.climate0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot(), + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("val", "mode", "temp"), + [ + ( + [ + [100, 0, "U", "M", 210, 0, 0, "U"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.HEAT, + 21.0, + ), + ( + [ + [100, 1, "U", "A", 210, 1, 0, "O"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.HEAT, + 21.0, + ), + ( + [ + [100, 0, "O", "A", 210, 0, 0, "O"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.OFF, + 21.0, + ), + ], +) +async def test_climate_data_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + val: list[Any, Any], + mode: HVACMode, + temp: float, +) -> None: + """Test climate data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=val, + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == mode + assert state.attributes[ATTR_TEMPERATURE] == temp + + +async def test_climate_data_update_bad_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val="bad_data", + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + +async def test_climate_set_temperature( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate set temperature service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 23}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 23.0 + + +async def test_climate_set_temperature_when_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate set temperature service when off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + # Switch climate off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 23}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + +async def test_climate_hvac_mode( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate hvac mode service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + +async def test_climate_hvac_mode_when_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate hvac mode service when off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.AUTO}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.AUTO diff --git a/tests/components/comelit/test_coordinator.py b/tests/components/comelit/test_coordinator.py new file mode 100644 index 00000000000..49e3164e875 --- /dev/null +++ b/tests/components/comelit/test_coordinator.py @@ -0,0 +1,49 @@ +"""Tests for Comelit SimpleHome coordinator.""" + +from unittest.mock import AsyncMock + +from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.parametrize( + "side_effect", + [ + CannotConnect, + CannotRetrieveData, + CannotAuthenticate, + ], +) +async def test_coordinator_data_update_fails( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test coordinator data update exceptions.""" + + entity_id = "light.light0" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + mock_serial_bridge.login.side_effect = side_effect + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py new file mode 100644 index 00000000000..1d6c1435a5a --- /dev/null +++ b/tests/components/comelit/test_cover.py @@ -0,0 +1,161 @@ +"""Tests for Comelit SimpleHome cover platform.""" + +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import COVER, WATT +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPENING, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "cover.cover0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.COVER]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot(), + mock_serial_bridge_config_entry.entry_id, + ) + + +async def test_cover_open( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover open service.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Open cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OPENING + + # Finish opening, update status + mock_serial_bridge.get_all_devices.return_value[COVER] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Cover0", + status=0, + human_status="stopped", + type="cover", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + +async def test_cover_close( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover close and stop service.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Close cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_CLOSING + + # Stop cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_CLOSED + + +async def test_cover_stop_if_stopped( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover stop service when already stopped.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Stop cover while not opening/closing + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_not_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/comelit/test_light.py b/tests/components/comelit/test_light.py new file mode 100644 index 00000000000..6c6de58c8ed --- /dev/null +++ b/tests/components/comelit/test_light.py @@ -0,0 +1,76 @@ +"""Tests for Comelit SimpleHome light platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "light.light0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.LIGHT]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot(), + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("service", "status"), + [ + (SERVICE_TURN_OFF, STATE_OFF), + (SERVICE_TURN_ON, STATE_ON), + (SERVICE_TOGGLE, STATE_ON), + ], +) +async def test_light_set_state( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + service: str, + status: str, +) -> None: + """Test light set state service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test set temperature + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == status diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py new file mode 100644 index 00000000000..8473158f662 --- /dev/null +++ b/tests/components/comelit/test_sensor.py @@ -0,0 +1,90 @@ +"""Tests for Comelit SimpleHome sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject +from aiocomelit.const import AlarmAreaState, AlarmZoneState +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "sensor.zone0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.VEDO_PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_vedo_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot(), + mock_vedo_config_entry.entry_id, + ) + + +async def test_sensor_state_unknown( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test sensor unknown state.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmZoneState.REST.value + + vedo_query = AlarmDataObject( + alarm_areas={ + 0: ComelitVedoAreaObject( + index=0, + name="Area0", + p1=True, + p2=True, + ready=False, + armed=True, + alarm=False, + alarm_memory=False, + sabotage=False, + anomaly=False, + in_time=False, + out_time=False, + human_status=AlarmAreaState.UNKNOWN, + ) + }, + alarm_zones={ + 0: ComelitVedoZoneObject( + index=0, + name="Zone0", + status_api="0x000", + status=0, + human_status=AlarmZoneState.UNKNOWN, + ) + }, + ) + + mock_vedo.get_all_areas_and_zones.return_value = vedo_query + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/comelit/test_switch.py b/tests/components/comelit/test_switch.py new file mode 100644 index 00000000000..fb9a4aab79a --- /dev/null +++ b/tests/components/comelit/test_switch.py @@ -0,0 +1,76 @@ +"""Tests for Comelit SimpleHome switch platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "switch.switch0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot(), + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("service", "status"), + [ + (SERVICE_TURN_OFF, STATE_OFF), + (SERVICE_TURN_ON, STATE_ON), + (SERVICE_TOGGLE, STATE_ON), + ], +) +async def test_switch_set_state( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + service: str, + status: str, +) -> None: + """Test switch set state service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test set temperature + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == status diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 739b79e22bd..c6e65c312bb 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader from homeassistant.components.config import config_entries -from homeassistant.config_entries import HANDLERS, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType @@ -34,13 +34,6 @@ from tests.common import ( from tests.typing import ClientSessionGenerator, WebSocketGenerator -@pytest.fixture -def clear_handlers() -> Generator[None]: - """Clear config entry handlers.""" - with patch.dict(HANDLERS, clear=True): - yield - - @pytest.fixture(autouse=True) def mock_test_component(hass: HomeAssistant) -> None: """Ensure a component called 'test' exists.""" @@ -74,7 +67,7 @@ def mock_flow() -> Generator[None]: @pytest.mark.usefixtures("freezer") -@pytest.mark.usefixtures("clear_handlers", "mock_flow") +@pytest.mark.usefixtures("mock_flow") async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: """Test get entries.""" mock_integration(hass, MockModule("comp1")) @@ -358,7 +351,7 @@ async def test_reload_entry_in_setup_retry( entry.add_to_hass(hass) hass.config.components.add("comp") - with patch.dict(HANDLERS, {"comp": ConfigFlow, "test": ConfigFlow}): + with mock_config_flow("comp", ConfigFlow), mock_config_flow("test", ConfigFlow): resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" ) @@ -422,7 +415,7 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None: errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "show_advanced_options": True}, @@ -471,7 +464,7 @@ async def test_initialize_flow_unmet_dependency( async def async_step_user(self, user_input=None): pass - with patch.dict(HANDLERS, {"test2": TestFlow}): + with mock_config_flow("test2", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test2", "show_advanced_options": True}, @@ -502,7 +495,7 @@ async def test_initialize_flow_unauth( errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -519,7 +512,7 @@ async def test_abort(hass: HomeAssistant, client: TestClient) -> None: async def async_step_user(self, user_input=None): return self.async_abort(reason="bla") - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -552,7 +545,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: title="Test Entry", data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -620,7 +613,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: title=user_input["user_title"], data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -638,7 +631,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/flow/{flow_id}", json={"user_title": "user-title"}, @@ -707,7 +700,7 @@ async def test_continue_flow_unauth( title=user_input["user_title"], data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -774,7 +767,7 @@ async def test_get_progress_index( assert self._get_reconfigure_entry() is entry return await self.async_step_account() - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): form_hassio = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_HASSIO} ) @@ -838,7 +831,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -874,7 +867,7 @@ async def test_get_progress_flow_unauth( errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -918,7 +911,7 @@ async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None: ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -980,7 +973,7 @@ async def test_options_flow_unauth( hass_admin_user.groups = [] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id}) assert resp.status == HTTPStatus.UNAUTHORIZED @@ -1017,7 +1010,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client: TestClient) -> ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -1035,7 +1028,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client: TestClient) -> "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/options/flow/{flow_id}", json={"enabled": True}, @@ -1092,7 +1085,7 @@ async def test_options_flow_with_invalid_data( ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -1118,7 +1111,7 @@ async def test_options_flow_with_invalid_data( "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/options/flow/{flow_id}", json={"choices": ["valid", "invalid"]}, @@ -1193,7 +1186,7 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: async def async_step_reconfigure(self, user_input=None): if user_input is not None: return self.async_update_and_abort( - self._get_reconfigure_entry(), + self._get_entry(), self._get_reconfigure_subentry(), title="Test Entry", data={"test": "blah"}, @@ -1812,7 +1805,7 @@ async def test_ignore_flow( ws_client = await hass_ws_client(hass) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): result = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_USER} | flow_context ) @@ -1861,7 +1854,7 @@ async def test_ignore_flow_nonexisting( assert response["error"]["code"] == "not_found" -@pytest.mark.usefixtures("clear_handlers", "freezer") +@pytest.mark.usefixtures("freezer") async def test_get_matching_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -2313,7 +2306,6 @@ async def test_get_matching_entries_ws( assert response["success"] is False -@pytest.mark.usefixtures("clear_handlers") async def test_subscribe_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2532,7 +2524,6 @@ async def test_subscribe_entries_ws( ] -@pytest.mark.usefixtures("clear_handlers") async def test_subscribe_entries_ws_filtered( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2792,7 +2783,7 @@ async def test_flow_with_multiple_schema_errors( ), ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -2834,7 +2825,7 @@ async def test_flow_with_multiple_schema_errors_base( ), ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -2893,7 +2884,7 @@ async def test_supports_reconfigure( data={"secret": "account_token"}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "entry_id": "1"}, @@ -2915,7 +2906,7 @@ async def test_supports_reconfigure( "errors": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/flow/{flow_id}", json={}, @@ -2953,7 +2944,7 @@ async def test_does_not_support_reconfigure( title="Test Entry", data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "entry_id": "1"}, diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 849a5b17102..abce735dd8a 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -32,6 +32,7 @@ 'it', 'ka', 'ko', + 'kw', 'lb', 'lt', 'lv', diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 6d69ec3c739..77fa97ad845 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -536,3 +536,60 @@ async def test_ws_hass_agent_debug_sentence_trigger( # Trigger should not have been executed assert len(calls) == 0 + + +async def test_ws_hass_language_scores( + hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting language support scores.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + {"type": "conversation/agent/homeassistant/language_scores"} + ) + + msg = await client.receive_json() + assert msg["success"] + + # Sanity check + result = msg["result"] + assert result["languages"]["en-US"] == { + "cloud": 3, + "focused_local": 2, + "full_local": 3, + } + + +async def test_ws_hass_language_scores_with_filter( + hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting language support scores with language/country filter.""" + client = await hass_ws_client(hass) + + # Language filter + await client.send_json_auto_id( + {"type": "conversation/agent/homeassistant/language_scores", "language": "de"} + ) + + msg = await client.receive_json() + assert msg["success"] + + # German should be preferred + result = msg["result"] + assert result["preferred_language"] == "de-DE" + + # Language/country filter + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/language_scores", + "language": "en", + "country": "GB", + } + ) + + msg = await client.receive_json() + assert msg["success"] + + # GB English should be preferred + result = msg["result"] + assert result["preferred_language"] == "en-GB" diff --git a/tests/components/conversation/test_util.py b/tests/components/conversation/test_util.py deleted file mode 100644 index 72a334232c1..00000000000 --- a/tests/components/conversation/test_util.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Test the conversation utils.""" - -from homeassistant.components.conversation.util import create_matcher - - -def test_create_matcher() -> None: - """Test the create matcher method.""" - # Basic sentence - pattern = create_matcher("Hello world") - assert pattern.match("Hello world") is not None - - # Match a part - pattern = create_matcher("Hello {name}") - match = pattern.match("hello world") - assert match is not None - assert match.groupdict()["name"] == "world" - no_match = pattern.match("Hello world, how are you?") - assert no_match is None - - # Optional and matching part - pattern = create_matcher("Turn on [the] {name}") - match = pattern.match("turn on the kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn off kitchen lights") - assert match is None - - # Two different optional parts, 1 matching part - pattern = create_matcher("Turn on [the] [a] {name}") - match = pattern.match("turn on the kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on a kitchen light") - assert match is not None - assert match.groupdict()["name"] == "kitchen light" - - # Strip plural - pattern = create_matcher("Turn {name}[s] on") - match = pattern.match("turn kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen light" - - # Optional 2 words - pattern = create_matcher("Turn [the great] {name} on") - match = pattern.match("turn the great kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index ebff89e1a15..860c470fc37 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -33,21 +33,19 @@ HOME_LONGITUDE = -117.237561 @pytest.fixture(autouse=True) -def setup_zone(hass: HomeAssistant) -> None: +async def setup_zone(hass: HomeAssistant) -> None: """Create test zone.""" - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": HOME_LATITUDE, - "longitude": HOME_LONGITUDE, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": HOME_LATITUDE, + "longitude": HOME_LONGITUDE, + "radius": 250, + } + }, ) diff --git a/tests/components/duckdns/test_init.py b/tests/components/duckdns/test_init.py index 313cc91aa18..7806d57e934 100644 --- a/tests/components/duckdns/test_init.py +++ b/tests/components/duckdns/test_init.py @@ -31,16 +31,16 @@ async def async_set_txt(hass: HomeAssistant, txt: str | None) -> None: @pytest.fixture -def setup_duckdns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_duckdns( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up DuckDNS.""" aioclient_mock.get( duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK" ) - hass.loop.run_until_complete( - async_setup_component( - hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} - ) + await async_setup_component( + hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} ) diff --git a/tests/components/duke_energy/conftest.py b/tests/components/duke_energy/conftest.py index f74ef43bf07..f82a2353557 100644 --- a/tests/components/duke_energy/conftest.py +++ b/tests/components/duke_energy/conftest.py @@ -61,8 +61,8 @@ def mock_api() -> Generator[AsyncMock]: ): api = mock_api.return_value api.authenticate.return_value = { - "email": "TEST@EXAMPLE.COM", - "cdp_internal_user_id": "test-username", + "loginEmailAddress": "TEST@EXAMPLE.COM", + "internalUserID": "test-username", } api.get_meters.return_value = {} yield api diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index ae1bc74df90..2c4af207642 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -4,9 +4,17 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.heater import EheimDigitalHeater from eheimdigital.hub import EheimDigitalHub -from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode +from eheimdigital.types import ( + EheimDeviceType, + FilterErrorCode, + FilterMode, + HeaterMode, + HeaterUnit, + LightMode, +) import pytest from homeassistant.components.eheimdigital.const import DOMAIN @@ -59,9 +67,28 @@ def heater_mock(): return heater_mock +@pytest.fixture +def classic_vario_mock(): + """Mock a classicVARIO device.""" + classic_vario_mock = MagicMock(spec=EheimDigitalClassicVario) + classic_vario_mock.mac_address = "00:00:00:00:00:03" + classic_vario_mock.device_type = EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + classic_vario_mock.name = "Mock classicVARIO" + classic_vario_mock.aquarium_name = "Mock Aquarium" + classic_vario_mock.sw_version = "1.0.0_1.0.0" + classic_vario_mock.current_speed = 75 + classic_vario_mock.is_active = True + classic_vario_mock.filter_mode = FilterMode.MANUAL + classic_vario_mock.error_code = FilterErrorCode.NO_ERROR + classic_vario_mock.service_hours = 360 + return classic_vario_mock + + @pytest.fixture def eheimdigital_hub_mock( - classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock + classic_led_ctrl_mock: MagicMock, + heater_mock: MagicMock, + classic_vario_mock: MagicMock, ) -> Generator[AsyncMock]: """Mock eheimdigital hub.""" with ( @@ -77,6 +104,7 @@ def eheimdigital_hub_mock( eheimdigital_hub_mock.return_value.devices = { "00:00:00:00:00:01": classic_led_ctrl_mock, "00:00:00:00:00:02": heater_mock, + "00:00:00:00:00:03": classic_vario_mock, } eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock yield eheimdigital_hub_mock diff --git a/tests/components/eheimdigital/snapshots/test_sensor.ambr b/tests/components/eheimdigital/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c5a3d700331 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_sensor.ambr @@ -0,0 +1,160 @@ +# serializer version: 1 +# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_classicvario_current_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_speed', + 'unique_id': '00:00:00:00:00:03_current_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Current speed', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_current_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'rotor_stuck', + 'air_in_filter', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_classicvario_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '00:00:00:00:00:03_error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock classicVARIO Error code', + 'options': list([ + 'no_error', + 'rotor_stuck', + 'air_in_filter', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_classicvario_remaining_hours_until_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining hours until service', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'service_hours', + 'unique_id': '00:00:00:00:00:03_service_hours', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock classicVARIO Remaining hours until service', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_remaining_hours_until_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/test_sensor.py b/tests/components/eheimdigital/test_sensor.py new file mode 100644 index 00000000000..ece4d3eb241 --- /dev/null +++ b/tests/components/eheimdigital/test_sensor.py @@ -0,0 +1,77 @@ +"""Tests for the sensor module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.types import EheimDeviceType, FilterErrorCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock") +async def test_setup_classic_vario( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor platform setup for the filter.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.SENSOR]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_vario_mock: MagicMock, +) -> None: + """Test the sensor state update.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + classic_vario_mock.current_speed = 10 + classic_vario_mock.error_code = FilterErrorCode.ROTOR_STUCK + classic_vario_mock.service_hours = 100 + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("sensor.mock_classicvario_current_speed")) + assert state.state == "10" + + assert (state := hass.states.get("sensor.mock_classicvario_error_code")) + assert state.state == "rotor_stuck" + + assert ( + state := hass.states.get( + "sensor.mock_classicvario_remaining_hours_until_service" + ) + ) + assert state.state == str(round(100 / 24, 1)) diff --git a/tests/components/emulated_roku/test_binding.py b/tests/components/emulated_roku/test_binding.py index 5bde72d2e4d..ec3f064dfe0 100644 --- a/tests/components/emulated_roku/test_binding.py +++ b/tests/components/emulated_roku/test_binding.py @@ -1,6 +1,7 @@ """Tests for emulated_roku library bindings.""" from unittest.mock import AsyncMock, Mock, patch +from uuid import uuid4 from homeassistant.components.emulated_roku.binding import ( ATTR_APP_ID, @@ -14,14 +15,15 @@ from homeassistant.components.emulated_roku.binding import ( ROKU_COMMAND_LAUNCH, EmulatedRoku, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant async def test_events_fired_properly(hass: HomeAssistant) -> None: """Test that events are fired correctly.""" - binding = EmulatedRoku( - hass, "Test Emulated Roku", "1.2.3.4", 8060, None, None, None - ) + random_name = uuid4().hex + # Note that this test is accessing the internal EmulatedRoku class + # and should be refactored in the future not to do so. + binding = EmulatedRoku(hass, "x", random_name, "1.2.3.4", 8060, None, None, None) events = [] roku_event_handler = None @@ -41,8 +43,9 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: return Mock(start=AsyncMock(), close=AsyncMock()) - def listener(event): - events.append(event) + def listener(event: Event) -> None: + if event.data[ATTR_SOURCE_NAME] == random_name: + events.append(event) with patch( "homeassistant.components.emulated_roku.binding.EmulatedRokuServer", instantiate @@ -53,10 +56,10 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: assert roku_event_handler is not None - roku_event_handler.on_keydown("Test Emulated Roku", "A") - roku_event_handler.on_keyup("Test Emulated Roku", "A") - roku_event_handler.on_keypress("Test Emulated Roku", "C") - roku_event_handler.launch("Test Emulated Roku", "1") + roku_event_handler.on_keydown(random_name, "A") + roku_event_handler.on_keyup(random_name, "A") + roku_event_handler.on_keypress(random_name, "C") + roku_event_handler.launch(random_name, "1") await hass.async_block_till_done() @@ -64,20 +67,20 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: assert events[0].event_type == EVENT_ROKU_COMMAND assert events[0].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYDOWN - assert events[0].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[0].data[ATTR_SOURCE_NAME] == random_name assert events[0].data[ATTR_KEY] == "A" assert events[1].event_type == EVENT_ROKU_COMMAND assert events[1].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYUP - assert events[1].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[1].data[ATTR_SOURCE_NAME] == random_name assert events[1].data[ATTR_KEY] == "A" assert events[2].event_type == EVENT_ROKU_COMMAND assert events[2].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYPRESS - assert events[2].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[2].data[ATTR_SOURCE_NAME] == random_name assert events[2].data[ATTR_KEY] == "C" assert events[3].event_type == EVENT_ROKU_COMMAND assert events[3].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_LAUNCH - assert events[3].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[3].data[ATTR_SOURCE_NAME] == random_name assert events[3].data[ATTR_APP_ID] == "1" diff --git a/tests/components/emulated_roku/test_init.py b/tests/components/emulated_roku/test_init.py index cf2a415f19c..473e0c662aa 100644 --- a/tests/components/emulated_roku/test_init.py +++ b/tests/components/emulated_roku/test_init.py @@ -86,16 +86,6 @@ async def test_setup_entry_successful(hass: HomeAssistant) -> None: assert await emulated_roku.async_setup_entry(hass, entry) is True assert len(instantiate.mock_calls) == 1 - assert hass.data[emulated_roku.DOMAIN] - - roku_instance = hass.data[emulated_roku.DOMAIN]["Emulated Roku Test"] - - assert roku_instance.roku_usn == "Emulated Roku Test" - assert roku_instance.host_ip == "1.2.3.5" - assert roku_instance.listen_port == 8060 - assert roku_instance.advertise_ip == "1.2.3.4" - assert roku_instance.advertise_port == 8071 - assert roku_instance.bind_multicast is False async def test_unload_entry(hass: HomeAssistant) -> None: @@ -113,10 +103,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: ): assert await emulated_roku.async_setup_entry(hass, entry) is True - assert emulated_roku.DOMAIN in hass.data - await hass.async_block_till_done() assert await emulated_roku.async_unload_entry(hass, entry) - - assert len(hass.data[emulated_roku.DOMAIN]) == 0 diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 959ec7d1687..e4b0e568a70 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -149,7 +149,13 @@ async def test_save_preferences( "stat_energy_to": "my_battery_charging", }, ], - "device_consumption": [{"stat_consumption": "some_device_usage"}], + "device_consumption": [ + { + "stat_consumption": "some_device_usage", + "name": "My Device", + "included_in_stat": "sensor.some_other_device", + } + ], } await client.send_json({"id": 6, "type": "energy/save_prefs", **new_prefs}) diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 152cf803258..69ef4ecaead 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -1373,7 +1373,66 @@ ]), }), 'fixtures': dict({ - 'Error': "EnvoyError('Test')", + '/admin/lib/tariff_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/api/v1/production/inverters_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/api/v1/production_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/info_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/dry_contacts_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/generator_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/inventory_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/power_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/secctrl_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/status_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/meters/readings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/meters_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/sc/pvlimit_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/dry_contact_settings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/gen_config_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/gen_schedule_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/pel_settings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production.json?details=1_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production.json_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production_log': dict({ + 'Error': "EnvoyError('Test')", + }), }), 'raw_data': dict({ 'varies_by': 'firmware_version', diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index c1e2c9270e2..101caaf1aea 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -361,7 +361,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -374,7 +374,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -1456,7 +1456,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1471,7 +1471,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1486,7 +1486,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -1495,22 +1495,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1525,7 +1525,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1540,7 +1540,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -1549,15 +1549,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2519,7 +2519,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -2532,7 +2532,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -5374,7 +5374,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5389,7 +5389,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5404,7 +5404,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5413,22 +5413,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5443,7 +5443,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5458,7 +5458,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5467,22 +5467,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.22', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5497,7 +5497,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5512,7 +5512,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5521,22 +5521,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5551,7 +5551,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5566,7 +5566,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5575,22 +5575,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.24', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5605,7 +5605,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5620,7 +5620,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5629,22 +5629,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.11', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l1-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5659,7 +5659,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5674,7 +5674,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5683,22 +5683,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.12', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l2-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5713,7 +5713,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5728,7 +5728,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5737,22 +5737,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.13', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l3-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5767,7 +5767,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5782,7 +5782,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5791,15 +5791,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -7026,7 +7026,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -7039,7 +7039,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -9881,7 +9881,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -9896,7 +9896,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9911,7 +9911,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -9920,22 +9920,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -9950,7 +9950,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9965,7 +9965,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -9974,22 +9974,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.22', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10004,7 +10004,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10019,7 +10019,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10028,22 +10028,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10058,7 +10058,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10073,7 +10073,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10082,22 +10082,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.24', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10112,7 +10112,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10127,7 +10127,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10136,22 +10136,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.11', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l1-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10166,7 +10166,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10181,7 +10181,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10190,22 +10190,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.12', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l2-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10220,7 +10220,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10235,7 +10235,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10244,22 +10244,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.13', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l3-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10274,7 +10274,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10289,7 +10289,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10298,15 +10298,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -11630,7 +11630,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11643,7 +11643,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -11688,7 +11688,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l1', + 'original_name': 'Balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11701,7 +11701,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l1', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l1', 'state_class': , 'unit_of_measurement': , }), @@ -11746,7 +11746,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l2', + 'original_name': 'Balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11759,7 +11759,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l2', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l2', 'state_class': , 'unit_of_measurement': , }), @@ -11804,7 +11804,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l3', + 'original_name': 'Balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11817,7 +11817,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l3', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l3', 'state_class': , 'unit_of_measurement': , }), @@ -17547,7 +17547,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17562,7 +17562,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17577,7 +17577,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17586,22 +17586,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17616,7 +17616,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17631,7 +17631,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17640,22 +17640,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.22', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17670,7 +17670,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17685,7 +17685,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17694,22 +17694,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17724,7 +17724,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17739,7 +17739,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17748,22 +17748,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.24', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17778,7 +17778,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17793,7 +17793,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17802,22 +17802,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.11', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l1-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17832,7 +17832,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17847,7 +17847,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17856,22 +17856,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.12', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l2-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17886,7 +17886,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17901,7 +17901,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17910,22 +17910,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.13', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l3-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17940,7 +17940,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17955,7 +17955,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17964,22 +17964,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.14', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17994,7 +17994,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18009,7 +18009,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT', + 'original_name': 'Power factor storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18018,22 +18018,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT', + 'friendly_name': 'Envoy 1234 Power factor storage CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l1-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18048,7 +18048,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18063,7 +18063,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT l1', + 'original_name': 'Power factor storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18072,22 +18072,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l1-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT l1', + 'friendly_name': 'Envoy 1234 Power factor storage CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.32', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l2-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18102,7 +18102,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18117,7 +18117,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT l2', + 'original_name': 'Power factor storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18126,22 +18126,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l2-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT l2', + 'friendly_name': 'Envoy 1234 Power factor storage CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l3-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18156,7 +18156,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18171,7 +18171,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT l3', + 'original_name': 'Power factor storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18180,15 +18180,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l3-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT l3', + 'friendly_name': 'Envoy 1234 Power factor storage CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -19586,7 +19586,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19599,7 +19599,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -19644,7 +19644,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l1', + 'original_name': 'Balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19657,7 +19657,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l1', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l1', 'state_class': , 'unit_of_measurement': , }), @@ -19702,7 +19702,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l2', + 'original_name': 'Balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19715,7 +19715,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l2', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l2', 'state_class': , 'unit_of_measurement': , }), @@ -19760,7 +19760,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l3', + 'original_name': 'Balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19773,7 +19773,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l3', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l3', 'state_class': , 'unit_of_measurement': , }), @@ -24065,7 +24065,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24080,7 +24080,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24095,7 +24095,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24104,22 +24104,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24134,7 +24134,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24149,7 +24149,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24158,22 +24158,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.22', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24188,7 +24188,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24203,7 +24203,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24212,22 +24212,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24242,7 +24242,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24257,7 +24257,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24266,22 +24266,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.24', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24296,7 +24296,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24311,7 +24311,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24320,22 +24320,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.11', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l1-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24350,7 +24350,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24365,7 +24365,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24374,22 +24374,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.12', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l2-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24404,7 +24404,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24419,7 +24419,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24428,22 +24428,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.13', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l3-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24458,7 +24458,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24473,7 +24473,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24482,15 +24482,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -25326,7 +25326,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -25339,7 +25339,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -25799,7 +25799,7 @@ 'state': 'normal', }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -25814,7 +25814,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -25829,7 +25829,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -25838,15 +25838,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 329a7b5179a..3f6db1dd9c9 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -25,7 +25,12 @@ from aioesphomeapi import ( ) import pytest -from homeassistant.components import assist_satellite, conversation, tts +from homeassistant.components import ( + assist_pipeline, + assist_satellite, + conversation, + tts, +) from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, @@ -53,6 +58,7 @@ from homeassistant.helpers import ( intent as intent_helper, ) from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.network import get_url from .conftest import MockESPHomeDevice @@ -128,8 +134,6 @@ async def test_pipeline_api_audio( ) -> None: """Test a complete pipeline run with API audio (over the TCP connection).""" conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" mock_device: MockESPHomeDevice = await mock_esphome_device( mock_client=mock_client, @@ -323,15 +327,22 @@ async def test_pipeline_api_audio( assert satellite.state == AssistSatelliteState.RESPONDING # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END, - {"url": media_url}, + {"url": get_url(hass) + mock_tts_result_stream.url}, ) event_callback(PipelineEvent(type=PipelineEventType.RUN_END)) @@ -350,12 +361,6 @@ async def test_pipeline_api_audio( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -368,10 +373,6 @@ async def test_pipeline_api_audio( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "_stream_tts_audio", _stream_tts_audio), patch.object(satellite, "tts_response_finished", tts_response_finished), @@ -429,8 +430,6 @@ async def test_pipeline_udp_audio( mainly focused on the UDP server. """ conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" mock_device: MockESPHomeDevice = await mock_esphome_device( mock_client=mock_client, @@ -517,10 +516,17 @@ async def test_pipeline_udp_audio( ) # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) @@ -533,12 +539,6 @@ async def test_pipeline_udp_audio( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -562,10 +562,6 @@ async def test_pipeline_udp_audio( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "tts_response_finished", tts_response_finished), ): @@ -647,8 +643,6 @@ async def test_pipeline_media_player( mainly focused on tts_response_finished getting automatically called. """ conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" mock_device: MockESPHomeDevice = await mock_esphome_device( mock_client=mock_client, @@ -728,10 +722,17 @@ async def test_pipeline_media_player( ) # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) @@ -744,12 +745,6 @@ async def test_pipeline_media_player( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -762,10 +757,6 @@ async def test_pipeline_media_player( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "tts_response_finished", tts_response_finished), ): @@ -939,80 +930,63 @@ async def test_streaming_tts_errors( # Should not stream if not running satellite._is_running = False - await satellite._stream_tts_audio("test-media-id") + await satellite._stream_tts_audio(MockResultStream(hass, "wav", mock_wav)) mock_client.send_voice_assistant_audio.assert_not_called() satellite._is_running = True # Should only stream WAV - async def get_mp3( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("mp3", b"") - - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_mp3 - ): - await satellite._stream_tts_audio("test-media-id") - mock_client.send_voice_assistant_audio.assert_not_called() + await satellite._stream_tts_audio(MockResultStream(hass, "mp3", b"")) + mock_client.send_voice_assistant_audio.assert_not_called() # Needs to be the correct sample rate, etc. - async def get_bad_wav( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - with io.BytesIO() as wav_io: - with wave.open(wav_io, "wb") as wav_file: - wav_file.setframerate(48000) - wav_file.setsampwidth(2) - wav_file.setnchannels(1) - wav_file.writeframes(b"test-wav") + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(48000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(b"test-wav") - return ("wav", wav_io.getvalue()) + mock_tts_result_stream = MockResultStream(hass, "wav", wav_io.getvalue()) - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_bad_wav - ): - await satellite._stream_tts_audio("test-media-id") - mock_client.send_voice_assistant_audio.assert_not_called() + await satellite._stream_tts_audio(mock_tts_result_stream) + mock_client.send_voice_assistant_audio.assert_not_called() # Check that TTS_STREAM_* events still get sent after cancel media_fetched = asyncio.Event() - async def get_slow_wav( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: + mock_tts_result_stream = MockResultStream(hass, "wav", b"") + + async def async_stream_result_slowly(): media_fetched.set() await asyncio.sleep(1) - return ("wav", mock_wav) + yield mock_wav + + mock_tts_result_stream.async_stream_result = async_stream_result_slowly mock_client.send_voice_assistant_event.reset_mock() - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_slow_wav - ): - task = asyncio.create_task(satellite._stream_tts_audio("test-media-id")) - async with asyncio.timeout(1): - # Wait for media to be fetched - await media_fetched.wait() - # Cancel task - task.cancel() - await task + task = asyncio.create_task(satellite._stream_tts_audio(mock_tts_result_stream)) + async with asyncio.timeout(1): + # Wait for media to be fetched + await media_fetched.wait() - # No audio should have gone out - mock_client.send_voice_assistant_audio.assert_not_called() - assert len(mock_client.send_voice_assistant_event.call_args_list) == 2 + # Cancel task + task.cancel() + await task - # The TTS_STREAM_* events should have gone out - assert mock_client.send_voice_assistant_event.call_args_list[-2].args == ( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, - {}, - ) - assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, - {}, - ) + # No audio should have gone out + mock_client.send_voice_assistant_audio.assert_not_called() + assert len(mock_client.send_voice_assistant_event.call_args_list) == 2 + + # The TTS_STREAM_* events should have gone out + assert mock_client.send_voice_assistant_event.call_args_list[-2].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, + {}, + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, + {}, + ) async def test_tts_format_from_media_player( @@ -1160,7 +1134,7 @@ async def test_announce_supported_features( Awaitable[MockESPHomeDevice], ], ) -> None: - """Test that the announce supported feature is set by flags.""" + """Test that the announce supported feature is not set by default.""" mock_device: MockESPHomeDevice = await mock_esphome_device( mock_client=mock_client, entity_info=[], @@ -1207,11 +1181,17 @@ async def test_announce_message( done = asyncio.Event() async def send_voice_assistant_announcement_await_response( - media_id: str, timeout: float, text: str + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str | None = None, ): assert satellite.state == AssistSatelliteState.RESPONDING assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" assert text == "test-text" + assert not start_conversation + assert not preannounce_media_id done.set() @@ -1238,7 +1218,11 @@ async def test_announce_message( await hass.services.async_call( assist_satellite.DOMAIN, "announce", - {"entity_id": satellite.entity_id, "message": "test-text"}, + { + "entity_id": satellite.entity_id, + "message": "test-text", + "preannounce": False, + }, blocking=True, ) await done.wait() @@ -1296,10 +1280,16 @@ async def test_announce_media_id( done = asyncio.Event() async def send_voice_assistant_announcement_await_response( - media_id: str, timeout: float, text: str + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str | None = None, ): assert satellite.state == AssistSatelliteState.RESPONDING assert media_id == "https://www.home-assistant.io/proxied.flac" + assert not start_conversation + assert not preannounce_media_id done.set() @@ -1321,6 +1311,7 @@ async def test_announce_media_id( { "entity_id": satellite.entity_id, "media_id": "https://www.home-assistant.io/resolved.mp3", + "preannounce": False, }, blocking=True, ) @@ -1328,9 +1319,9 @@ async def test_announce_media_id( assert satellite.state == AssistSatelliteState.IDLE mock_async_create_proxy_url.assert_called_once_with( - hass, - dev.id, - "https://www.home-assistant.io/resolved.mp3", + hass=hass, + device_id=dev.id, + media_url="https://www.home-assistant.io/resolved.mp3", media_format="flac", rate=48000, channels=2, @@ -1338,6 +1329,422 @@ async def test_announce_media_id( ) +async def test_announce_message_with_preannounce( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test announcement with message and preannounce media id.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str | None = None, + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" + assert text == "test-text" + assert not start_conversation + assert preannounce_media_id == "test-preannounce" + + done.set() + + with ( + patch( + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "announce", + { + "entity_id": satellite.entity_id, + "message": "test-text", + "preannounce_media_id": "test-preannounce", + }, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + +async def test_start_conversation_supported_features( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that the start conversation supported feature is not set by default.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + assert not ( + satellite.supported_features & AssistSatelliteEntityFeature.START_CONVERSATION + ) + + +async def test_start_conversation_message( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test start conversation with message.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + | VoiceAssistantFeature.START_CONVERSATION + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test engine", + conversation_language="en", + language="en", + name="test pipeline", + stt_engine="test stt", + stt_language="en", + tts_engine="test tts", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str, + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" + assert text == "test-text" + assert start_conversation + assert not preannounce_media_id + + done.set() + + with ( + patch( + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipeline", + return_value=pipeline, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "start_conversation", + { + "entity_id": satellite.entity_id, + "start_message": "test-text", + "preannounce": False, + }, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + +async def test_start_conversation_media_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + device_registry: dr.DeviceRegistry, +) -> None: + """Test start conversation with media id.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[ + MediaPlayerInfo( + object_id="mymedia_player", + key=1, + name="my media_player", + unique_id="my_media_player", + supports_pause=True, + supported_formats=[ + MediaPlayerSupportedFormat( + format="flac", + sample_rate=48000, + num_channels=2, + purpose=MediaPlayerFormatPurpose.ANNOUNCEMENT, + sample_bytes=2, + ), + ], + ) + ], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + | VoiceAssistantFeature.START_CONVERSATION + }, + ) + await hass.async_block_till_done() + + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} + ) + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test engine", + conversation_language="en", + language="en", + name="test pipeline", + stt_engine="test stt", + stt_language="en", + tts_engine="test tts", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str, + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "https://www.home-assistant.io/proxied.flac" + assert start_conversation + assert not preannounce_media_id + + done.set() + + with ( + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + patch( + "homeassistant.components.esphome.assist_satellite.async_create_proxy_url", + return_value="https://www.home-assistant.io/proxied.flac", + ) as mock_async_create_proxy_url, + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipeline", + return_value=pipeline, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "start_conversation", + { + "entity_id": satellite.entity_id, + "start_media_id": "https://www.home-assistant.io/resolved.mp3", + "preannounce": False, + }, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + mock_async_create_proxy_url.assert_called_once_with( + hass=hass, + device_id=dev.id, + media_url="https://www.home-assistant.io/resolved.mp3", + media_format="flac", + rate=48000, + channels=2, + width=2, + ) + + +async def test_start_conversation_message_with_preannounce( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test start conversation with message and preannounce media id.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + | VoiceAssistantFeature.START_CONVERSATION + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test engine", + conversation_language="en", + language="en", + name="test pipeline", + stt_engine="test stt", + stt_language="en", + tts_engine="test tts", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str, + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" + assert text == "test-text" + assert start_conversation + assert preannounce_media_id == "test-preannounce" + + done.set() + + with ( + patch( + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipeline", + return_value=pipeline, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "start_conversation", + { + "entity_id": satellite.entity_id, + "start_message": "test-text", + "preannounce_media_id": "test-preannounce", + }, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + async def test_satellite_unloaded_on_disconnect( hass: HomeAssistant, mock_client: APIClient, diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index afca6f76b43..d48a1f40482 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1047,6 +1047,36 @@ async def test_reauth_confirm_invalid_with_unique_id( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf") +async def test_reauth_encryption_key_removed( + hass: HomeAssistant, mock_client, mock_setup_entry: None +) -> None: + """Test reauth when the encryption key was removed.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + }, + unique_id="test", + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_encryption_removed_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_NOISE_PSK] == "" + + async def test_discovery_dhcp_updates_host( hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None ) -> None: diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 296d61b664d..977ec50ab30 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -260,6 +260,76 @@ async def test_entities_removed_after_reload( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 +async def test_entities_for_entire_platform_removed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test removing all entities for a specific platform when static info changes.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor_to_be_removed", + key=1, + name="my binary_sensor to be removed", + unique_id="mybinary_sensor_to_be_removed", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + entry = mock_device.entry + entry_id = entry.entry_id + storage_key = f"esphome.{entry_id}" + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + assert state.state == STATE_ON + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 + + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) + assert reg_entry is not None + assert state.attributes[ATTR_RESTORED] is True + + entity_info = [] + states = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + entry=entry, + ) + assert mock_device.entry.entry_id == entry_id + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is None + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) + assert reg_entry is None + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 0 + + async def test_entity_info_object_ids( hass: HomeAssistant, mock_client: APIClient, diff --git a/tests/components/esphome/test_event.py b/tests/components/esphome/test_event.py index c17dc4d98a9..d4688e8ab4e 100644 --- a/tests/components/esphome/test_event.py +++ b/tests/components/esphome/test_event.py @@ -4,6 +4,7 @@ from aioesphomeapi import APIClient, Event, EventInfo import pytest from homeassistant.components.event import EventDeviceClass +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -11,9 +12,9 @@ from homeassistant.core import HomeAssistant async def test_generic_event_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_esphome_device, ) -> None: - """Test a generic event entity.""" + """Test a generic event entity and its availability behavior.""" entity_info = [ EventInfo( object_id="myevent", @@ -26,13 +27,31 @@ async def test_generic_event_entity( ] states = [Event(key=1, event_type="type1")] user_service = [] - await mock_generic_device_entry( + device = await mock_esphome_device( mock_client=mock_client, entity_info=entity_info, user_service=user_service, states=states, ) + await hass.async_block_till_done() + + # Test initial state state = hass.states.get("event.test_myevent") assert state is not None assert state.state == "2024-04-24T00:00:00.000+00:00" assert state.attributes["event_type"] == "type1" + + # Test device becomes unavailable + await device.mock_disconnect(True) + await hass.async_block_till_done() + state = hass.states.get("event.test_myevent") + assert state.state == STATE_UNAVAILABLE + + # Test device becomes available again + await device.mock_connect() + await hass.async_block_till_done() + + # Event entity should be available immediately without waiting for data + state = hass.states.get("event.test_myevent") + assert state.state == "2024-04-24T00:00:00.000+00:00" + assert state.attributes["event_type"] == "type1" diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 5060471f5d2..910463f6e30 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import patch from aioesphomeapi import ( APIClient, @@ -86,26 +86,28 @@ def stub_reconnect(): ) async def test_update_entity( hass: HomeAssistant, - stub_reconnect, - mock_config_entry, - mock_device_info, mock_dashboard: dict[str, Any], - devices_payload, - expected_state, - expected_attributes, + devices_payload: list[dict[str, Any]], + expected_state: str, + expected_attributes: dict[str, Any], + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], ) -> None: """Test ESPHome update entity.""" mock_dashboard["configured"] = devices_payload await async_get_dashboard(hass).async_refresh() - with patch( - "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info, info={}), - ): - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state is not None assert state.state == expected_state for key, expected_value in expected_attributes.items(): @@ -130,7 +132,7 @@ async def test_update_entity( await hass.services.async_call( "update", "install", - {"entity_id": "update.none_firmware"}, + {"entity_id": "update.test_firmware"}, blocking=True, ) @@ -155,7 +157,7 @@ async def test_update_entity( await hass.services.async_call( "update", "install", - {"entity_id": "update.none_firmware"}, + {"entity_id": "update.test_firmware"}, blocking=True, ) @@ -177,7 +179,7 @@ async def test_update_entity( await hass.services.async_call( "update", "install", - {"entity_id": "update.none_firmware"}, + {"entity_id": "update.test_firmware"}, blocking=True, ) @@ -274,28 +276,30 @@ async def test_update_device_state_for_availability( async def test_update_entity_dashboard_not_available_startup( hass: HomeAssistant, - stub_reconnect, - mock_config_entry, - mock_device_info, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity when dashboard is not available at startup.""" with ( - patch( - "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info, info={}), - ), patch( "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", side_effect=TimeoutError, ), ): await async_get_dashboard(hass).async_refresh() - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) # We have a dashboard but it is not available - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state is None mock_dashboard["configured"] = [ @@ -308,7 +312,7 @@ async def test_update_entity_dashboard_not_available_startup( await async_get_dashboard(hass).async_refresh() await hass.async_block_till_done() - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state.state == STATE_ON expected_attributes = { "latest_version": "2023.2.0-dev", @@ -370,17 +374,22 @@ async def test_update_entity_dashboard_discovered_after_startup_but_update_faile async def test_update_entity_not_present_without_dashboard( - hass: HomeAssistant, stub_reconnect, mock_config_entry, mock_device_info + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], ) -> None: """Test ESPHome update entity does not get created if there is no dashboard.""" - with patch( - "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info, info={}), - ): - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state is None diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 55b7e35132c..9e7c2f6c003 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from pyfibaro.fibaro_device import SceneEvent import pytest from homeassistant.components.fibaro import CONF_IMPORT_PLUGINS, DOMAIN @@ -231,6 +232,26 @@ def mock_fan_device() -> Mock: return climate +@pytest.fixture +def mock_button_device() -> Mock: + """Fixture for a button device.""" + climate = Mock() + climate.fibaro_id = 8 + climate.parent_fibaro_id = 0 + climate.name = "Test button" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.remoteController" + climate.base_type = "com.fibaro.actor" + climate.properties = {"manufacturer": ""} + climate.central_scene_event = [SceneEvent(1, "Pressed")] + climate.actions = {} + climate.interfaces = ["zwaveCentralScene"] + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/fibaro/test_event.py b/tests/components/fibaro/test_event.py new file mode 100644 index 00000000000..ced39b71197 --- /dev/null +++ b/tests/components/fibaro/test_event.py @@ -0,0 +1,35 @@ +"""Test the Fibaro event platform.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_entity_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_button_device: Mock, + mock_room: Mock, +) -> None: + """Test that the button device creates an entity.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_button_device] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.EVENT]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + entry = entity_registry.async_get("event.room_1_test_button_8_button_1") + assert entry + assert entry.unique_id == "hc2_111111.8.1" + assert entry.original_name == "Room 1 Test button Button 1" diff --git a/tests/components/fibaro/test_init.py b/tests/components/fibaro/test_init.py new file mode 100644 index 00000000000..330de74d6af --- /dev/null +++ b/tests/components/fibaro/test_init.py @@ -0,0 +1,31 @@ +"""Test init methods.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_unload_integration( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test unload integration stops state listener.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + # Act + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + # Assert + assert mock_fibaro_client.unregister_update_handler.call_count == 1 diff --git a/tests/components/flux_led/test_config_flow.py b/tests/components/flux_led/test_config_flow.py index f486d27244e..14ac4dd23ab 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -356,6 +356,60 @@ async def test_manual_working_discovery(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" +async def test_user_flow_can_replace_ignored(hass: HomeAssistant) -> None: + """Test a user flow can replace an ignored entry.""" + ignored_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + title=DEFAULT_ENTRY_TITLE, + source=config_entries.SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + # Cannot connect (timeout) + with _patch_discovery(no_device=True), _patch_wifibulb(no_device=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + # Success + with ( + _patch_discovery(), + _patch_wifibulb(), + patch(f"{MODULE}.async_setup", return_value=True), + patch(f"{MODULE}.async_setup_entry", return_value=True), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == DEFAULT_ENTRY_TITLE + assert result4["data"] == { + CONF_MINOR_VERSION: 4, + CONF_HOST: IP_ADDRESS, + CONF_MODEL: MODEL, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_INFO: MODEL, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_HOST: "the.cloud", + CONF_REMOTE_ACCESS_PORT: 8816, + } + + async def test_manual_no_discovery_data(hass: HomeAssistant) -> None: """Test manually setup without discovery data.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/freedns/test_init.py b/tests/components/freedns/test_init.py index d142fd767e1..eab0a1793ce 100644 --- a/tests/components/freedns/test_init.py +++ b/tests/components/freedns/test_init.py @@ -16,7 +16,9 @@ UPDATE_URL = freedns.UPDATE_URL @pytest.fixture -def setup_freedns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_freedns( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up FreeDNS.""" params = {} params[ACCESS_TOKEN] = "" @@ -24,17 +26,15 @@ def setup_freedns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> N UPDATE_URL, params=params, text="Successfully updated 1 domains." ) - hass.loop.run_until_complete( - async_setup_component( - hass, - freedns.DOMAIN, - { - freedns.DOMAIN: { - "access_token": ACCESS_TOKEN, - "scan_interval": UPDATE_INTERVAL, - } - }, - ) + await async_setup_component( + hass, + freedns.DOMAIN, + { + freedns.DOMAIN: { + "access_token": ACCESS_TOKEN, + "scan_interval": UPDATE_INTERVAL, + } + }, ) diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index 5ff0e448b15..ffdd3d23f50 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -357,7 +357,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_download_noise_margin', 'has_entity_name': True, 'hidden_by': None, @@ -405,7 +405,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_download_power_attenuation', 'has_entity_name': True, 'hidden_by': None, @@ -453,7 +453,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_download_throughput', 'has_entity_name': True, 'hidden_by': None, @@ -502,7 +502,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_upload_noise_margin', 'has_entity_name': True, 'hidden_by': None, @@ -550,7 +550,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', 'has_entity_name': True, 'hidden_by': None, @@ -598,7 +598,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_upload_throughput', 'has_entity_name': True, 'hidden_by': None, @@ -647,7 +647,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.mock_title_max_connection_download_throughput', 'has_entity_name': True, 'hidden_by': None, @@ -696,7 +696,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 034b86497db..1f310e1d3cb 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -25,7 +25,7 @@ async def setup_config_entry( device: Mock | None = None, fritz: Mock | None = None, template: Mock | None = None, -) -> bool: +) -> MockConfigEntry: """Do setup of a MockConfigEntry.""" entry = MockConfigEntry( domain=DOMAIN, @@ -39,10 +39,10 @@ async def setup_config_entry( if template is not None and fritz is not None: fritz().get_templates.return_value = [template] - result = await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) if device is not None: await hass.async_block_till_done() - return result + return entry def set_devices( diff --git a/tests/components/fritzbox/snapshots/test_binary_sensor.ambr b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..5b3e00dfa93 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_setup[binary_sensor.fake_name_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fake_name_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm', + 'unique_id': '12345 1234567_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'fake_name Alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_on_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.fake_name_button_lock_on_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button lock on device', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '12345 1234567_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_on_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'fake_name Button lock on device', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_button_lock_on_device', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_via_ui-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.fake_name_button_lock_via_ui', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button lock via UI', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_lock', + 'unique_id': '12345 1234567_device_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_via_ui-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'fake_name Button lock via UI', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_button_lock_via_ui', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_button.ambr b/tests/components/fritzbox/snapshots/test_button.ambr new file mode 100644 index 00000000000..95e757da3cc --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_button.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup[button.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[button.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name', + }), + 'context': , + 'entity_id': 'button.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_climate.ambr b/tests/components/fritzbox/snapshots/test_climate.ambr new file mode 100644 index 00000000000..26e06105152 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_climate.ambr @@ -0,0 +1,80 @@ +# serializer version: 1 +# name: test_setup[climate.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 8.0, + 'preset_modes': list([ + 'eco', + 'comfort', + 'boost', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'thermostat', + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[climate.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_level': 23, + 'battery_low': True, + 'current_temperature': 18.0, + 'friendly_name': 'fake_name', + 'holiday_mode': False, + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 8.0, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'comfort', + 'boost', + ]), + 'summer_mode': False, + 'supported_features': , + 'temperature': 19.5, + 'window_open': 'fake_window', + }), + 'context': , + 'entity_id': 'climate.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_cover.ambr b/tests/components/fritzbox/snapshots/test_cover.ambr new file mode 100644 index 00000000000..ce6b305e154 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_cover.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_setup[cover.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[cover.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'blind', + 'friendly_name': 'fake_name', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_light.ambr b/tests/components/fritzbox/snapshots/test_light.ambr new file mode 100644 index 00000000000..f6f4516bdec --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_light.ambr @@ -0,0 +1,278 @@ +# serializer version: 1 +# name: test_setup[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'color_temp': 370, + 'color_temp_kelvin': 2700, + 'friendly_name': 'fake_name', + 'hs_color': tuple( + 28.395, + 65.723, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 167, + 87, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.525, + 0.388, + ), + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_color[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_color[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'fake_name', + 'hs_color': tuple( + 100, + 70.0, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'rgb_color': tuple( + 136, + 255, + 77, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.271, + 0.609, + ), + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_non_color[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_non_color[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'friendly_name': 'fake_name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_non_color_non_level[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_non_color_non_level[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'fake_name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_sensor.ambr b/tests/components/fritzbox/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..68f8e161d07 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_sensor.ambr @@ -0,0 +1,810 @@ +# serializer version: 1 +# name: test_setup[FritzDeviceBinarySensorMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceBinarySensorMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_comfort_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_comfort_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Comfort temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'comfort_temperature', + 'unique_id': '12345 1234567_comfort_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_comfort_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Comfort temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_comfort_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_current_scheduled_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_current_scheduled_preset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current scheduled preset', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'scheduled_preset', + 'unique_id': '12345 1234567_scheduled_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_current_scheduled_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Current scheduled preset', + }), + 'context': , + 'entity_id': 'sensor.fake_name_current_scheduled_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'eco', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_eco_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_eco_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eco temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'eco_temperature', + 'unique_id': '12345 1234567_eco_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_eco_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Eco temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_eco_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_change_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_change_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next scheduled change time', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_time', + 'unique_id': '12345 1234567_nextchange_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_change_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'fake_name Next scheduled change time', + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_change_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-09-20T18:00:00+00:00', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_preset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next scheduled preset', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_preset', + 'unique_id': '12345 1234567_nextchange_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Next scheduled preset', + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'comfort', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next scheduled temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_temperature', + 'unique_id': '12345 1234567_nextchange_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Next scheduled temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'fake_name Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_electric_current', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'fake_name Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.025', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_total_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'fake_name Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_power_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'fake_name Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.678', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'fake_name Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_switch.ambr b/tests/components/fritzbox/snapshots/test_switch.ambr new file mode 100644 index 00000000000..23deb8183fc --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup[switch.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[switch.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name', + }), + 'context': , + 'entity_id': 'switch.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index 594ed14a7d1..3244d007fa6 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -2,83 +2,49 @@ from datetime import timedelta from unittest import mock -from unittest.mock import Mock +from unittest.mock import Mock, patch from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion -from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, - BinarySensorDeviceClass, -) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzDeviceBinarySensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{BINARY_SENSOR_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.BINARY_SENSOR]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(f"{ENTITY_ID}_alarm") - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Alarm" - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_button_lock_on_device") - assert state - assert state.state == STATE_OFF - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Button lock on device" - ) - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_button_lock_via_ui") - assert state - assert state.state == STATE_OFF - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Button lock via UI" - ) - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") - assert state - assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert ATTR_STATE_CLASS not in state.attributes + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: """Test state of platform.""" device = FritzDeviceBinarySensorMock() device.present = False - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -98,7 +64,7 @@ async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -117,7 +83,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceBinarySensorMock() device.update.side_effect = [mock.DEFAULT, HTTPError("Boom")] - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -135,7 +101,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 0053a8d3446..5280cd7cc83 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -1,44 +1,50 @@ """Tests for AVM Fritz!Box templates.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch + +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - CONF_DEVICES, - STATE_UNKNOWN, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICES, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzEntityBaseMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{BUTTON_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test if is initialized correctly.""" template = FritzEntityBaseMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.BUTTON]): + entry = await setup_config_entry( + hass, + MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + fritz=fritz, + template=template, + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.state == STATE_UNKNOWN + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: """Test if applies works.""" template = FritzEntityBaseMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template ) @@ -51,7 +57,7 @@ async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" template = FritzEntityBaseMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template ) diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 0784d7b6188..e21191fcbbb 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -1,11 +1,12 @@ """Tests for AVM Fritz!Box climate component.""" from datetime import timedelta -from unittest.mock import Mock, _Call, call +from unittest.mock import Mock, _Call, call, patch from freezegun.api import FrozenDateTimeFactory import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -16,6 +17,7 @@ from homeassistant.components.climate import ( ATTR_PRESET_MODE, ATTR_PRESET_MODES, DOMAIN as CLIMATE_DOMAIN, + PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, SERVICE_SET_HVAC_MODE, @@ -30,25 +32,15 @@ from homeassistant.components.fritzbox.climate import ( PRESET_SUMMER, ) from homeassistant.components.fritzbox.const import ( - ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, ATTR_STATE_SUMMER_MODE, - ATTR_STATE_WINDOW_OPEN, DOMAIN as FB_DOMAIN, ) -from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - UnitOfTemperature, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_DEVICES, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( @@ -59,123 +51,31 @@ from . import ( ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{CLIMATE_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.CLIMATE]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) - state = hass.states.get(ENTITY_ID) - assert state - assert state.attributes[ATTR_BATTERY_LEVEL] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 18 - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] - assert state.attributes[ATTR_MAX_TEMP] == 28 - assert state.attributes[ATTR_MIN_TEMP] == 8 - assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] - assert state.attributes[ATTR_STATE_BATTERY_LOW] is True - assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False - assert state.attributes[ATTR_STATE_SUMMER_MODE] is False - assert state.attributes[ATTR_STATE_WINDOW_OPEN] == "fake_window" - assert state.attributes[ATTR_TEMPERATURE] == 19.5 - assert ATTR_STATE_CLASS not in state.attributes - assert state.state == HVACMode.HEAT - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") - assert state - assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_comfort_temperature") - assert state - assert state.state == "22.0" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Comfort temperature" - ) - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_eco_temperature") - assert state - assert state.state == "16.0" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Eco temperature" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_temperature" - ) - assert state - assert state.state == "22.0" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled temperature" - ) - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_change_time" - ) - assert state - assert state.state == "2024-09-20T18:00:00+00:00" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled change time" - ) - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") - assert state - assert state.state == PRESET_COMFORT - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled preset" - ) - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" - ) - assert state - assert state.state == PRESET_ECO - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Current scheduled preset" - ) - assert ATTR_STATE_CLASS not in state.attributes - - device.nextchange_temperature = 16 - - next_update = dt_util.utcnow() + timedelta(seconds=200) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") - assert state - assert state.state == PRESET_ECO - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" - ) - assert state - assert state.state == PRESET_COMFORT + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_hkr_wo_temperature_sensor(hass: HomeAssistant, fritz: Mock) -> None: """Test hkr without exposing dedicated temperature sensor data block.""" device = FritzDeviceClimateWithoutTempSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -188,7 +88,7 @@ async def test_target_temperature_on(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceClimateMock() device.target_temperature = 127.0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -201,7 +101,7 @@ async def test_target_temperature_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceClimateMock() device.target_temperature = 126.5 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -213,7 +113,7 @@ async def test_target_temperature_off(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -244,7 +144,7 @@ async def test_automatic_offset(hass: HomeAssistant, fritz: Mock) -> None: device.temperature = 18 device.actual_temperature = 19 device.target_temperature = 20 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -260,9 +160,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceClimateMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -303,7 +204,7 @@ async def test_set_temperature( ) -> None: """Test setting temperature.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -357,7 +258,7 @@ async def test_set_hvac_mode( else: device.nextchange_endperiod = 0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -389,7 +290,7 @@ async def test_set_preset_mode_comfort( """Test setting preset mode.""" device = FritzDeviceClimateMock() device.comfort_temperature = comfort_temperature - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -420,7 +321,7 @@ async def test_set_preset_mode_eco( """Test setting preset mode.""" device = FritzDeviceClimateMock() device.eco_temperature = eco_temperature - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -434,12 +335,32 @@ async def test_set_preset_mode_eco( assert device.set_target_temperature.call_args_list == expected_call_args +async def test_set_preset_mode_boost( + hass: HomeAssistant, + fritz: Mock, +) -> None: + """Test setting preset mode.""" + device = FritzDeviceClimateMock() + await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_BOOST}, + True, + ) + assert device.set_target_temperature.call_count == 1 + assert device.set_target_temperature.call_args_list == [call(30, True)] + + async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: """Test preset mode.""" device = FritzDeviceClimateMock() - device.comfort_temperature = 98 - device.eco_temperature = 99 - assert await setup_config_entry( + device.comfort_temperature = 23 + device.eco_temperature = 20 + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -447,8 +368,8 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: assert state assert state.attributes[ATTR_PRESET_MODE] is None - device.target_temperature = 98 - + # test comfort preset + device.target_temperature = 23 next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) @@ -458,8 +379,8 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: assert state assert state.attributes[ATTR_PRESET_MODE] == PRESET_COMFORT - device.target_temperature = 99 - + # test eco preset + device.target_temperature = 20 next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) @@ -469,11 +390,22 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: assert state assert state.attributes[ATTR_PRESET_MODE] == PRESET_ECO + # test boost preset + device.target_temperature = 127 # special temp from the api + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get(ENTITY_ID) + + assert fritz().update_devices.call_count == 4 + assert state + assert state.attributes[ATTR_PRESET_MODE] == PRESET_BOOST + async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -498,7 +430,7 @@ async def test_holidy_summer_mode( ) -> None: """Test holiday and summer mode.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -509,7 +441,11 @@ async def test_holidy_summer_mode( assert state.attributes[ATTR_STATE_SUMMER_MODE] is False assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] + assert state.attributes[ATTR_PRESET_MODES] == [ + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] # test holiday mode device.holiday_active = True @@ -596,4 +532,8 @@ async def test_holidy_summer_mode( assert state.attributes[ATTR_STATE_SUMMER_MODE] is False assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] + assert state.attributes[ATTR_PRESET_MODES] == [ + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index 535306e4ef2..a1332e9715b 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -1,15 +1,13 @@ """Tests for AVM Fritz!Box switch component.""" from datetime import timedelta -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch -from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_POSITION, - DOMAIN as COVER_DOMAIN, - CoverState, -) +from syrupy import SnapshotAssertion + +from homeassistant.components.cover import ATTR_POSITION, DOMAIN as COVER_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICES, @@ -18,8 +16,10 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( @@ -30,28 +30,32 @@ from . import ( ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{COVER_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.COVER]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_unknown_position(hass: HomeAssistant, fritz: Mock) -> None: """Test cover with unknown position.""" device = FritzDeviceCoverUnknownPositionMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -63,7 +67,7 @@ async def test_unknown_position(hass: HomeAssistant, fritz: Mock) -> None: async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test opening the cover.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -76,7 +80,7 @@ async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test closing the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -89,7 +93,7 @@ async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test stopping the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -105,7 +109,7 @@ async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test stopping the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -118,7 +122,7 @@ async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index fe8bb32066e..d9a81bf8f21 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -1,9 +1,10 @@ """Tests for AVM Fritz!Box light component.""" from datetime import timedelta -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.fritzbox.const import ( COLOR_MODE, @@ -12,35 +13,36 @@ from homeassistant.components.fritzbox.const import ( ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, - ColorMode, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, CONF_DEVICES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzDeviceLightMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{LIGHT_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceLightMock() device.get_color_temps.return_value = [2700, 6500] @@ -50,42 +52,42 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: device.color_mode = COLOR_TEMP_MODE device.color_temp = 2700 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2700 - assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 - assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 - assert state.attributes[ATTR_HS_COLOR] == (28.395, 65.723) - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_non_color(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_non_color( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform of non color bulb.""" device = FritzDeviceLightMock() device.has_color = False device.get_color_temps.return_value = [] device.get_colors.return_value = {} - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_BRIGHTNESS] == 100 - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_non_color_non_level( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform of non color and non level bulb.""" device = FritzDeviceLightMock() device.has_color = False @@ -93,22 +95,21 @@ async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> No device.get_color_temps.return_value = [] device.get_colors.return_value = {} - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert ATTR_BRIGHTNESS not in state.attributes - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.ONOFF - assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None - assert state.attributes.get(ATTR_HS_COLOR) is None + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_color( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform in color mode.""" device = FritzDeviceLightMock() device.get_color_temps.return_value = [2700, 6500] @@ -119,19 +120,13 @@ async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: device.hue = 100 device.saturation = 70 * 255.0 / 100.0 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] is None - assert state.attributes[ATTR_BRIGHTNESS] == 100 - assert state.attributes[ATTR_HS_COLOR] == (100, 70) - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: @@ -258,9 +253,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 67b2c3e8ab6..28d21f9fd39 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -1,97 +1,69 @@ """Tests for AVM Fritz!Box sensor component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.climate import PRESET_COMFORT, PRESET_ECO from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - STATE_UNKNOWN, - EntityCategory, - UnitOfTemperature, -) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( + FritzDeviceBinarySensorMock, FritzDeviceClimateMock, FritzDeviceSensorMock, + FritzDeviceSwitchMock, + FritzEntityBaseMock, set_devices, setup_config_entry, ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}" +@pytest.mark.parametrize( + "device", + [ + FritzDeviceBinarySensorMock, + FritzDeviceClimateMock, + FritzDeviceSensorMock, + FritzDeviceSwitchMock, + ], +) async def test_setup( - hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, + device: FritzEntityBaseMock, ) -> None: - """Test setup of platform.""" - device = FritzDeviceSensorMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - await hass.async_block_till_done() + """Test setup of sensor platform for different device types.""" + device = device() - sensors = ( - [ - f"{ENTITY_ID}_temperature", - "1.23", - f"{CONF_FAKE_NAME} Temperature", - UnitOfTemperature.CELSIUS, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{ENTITY_ID}_humidity", - "42", - f"{CONF_FAKE_NAME} Humidity", - PERCENTAGE, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{ENTITY_ID}_battery", - "23", - f"{CONF_FAKE_NAME} Battery", - PERCENTAGE, - None, - EntityCategory.DIAGNOSTIC, - ], - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SENSOR]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - for sensor in sensors: - state = hass.states.get(sensor[0]) - assert state - assert state.state == sensor[1] - assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] - assert state.attributes.get(ATTR_STATE_CLASS) == sensor[4] - entry = entity_registry.async_get(sensor[0]) - assert entry - assert entry.entity_category is sensor[5] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) assert fritz().update_devices.call_count == 1 @@ -109,9 +81,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceSensorMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -126,7 +99,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -175,7 +148,7 @@ async def test_next_change_sensors( device.nextchange_endperiod = next_changes[0] device.nextchange_temperature = next_changes[1] - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 511725c663f..cb6b563d344 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -1,33 +1,22 @@ """Tests for AVM Fritz!Box switch component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_ON, STATE_UNAVAILABLE, - EntityCategory, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -37,89 +26,32 @@ from homeassistant.util import dt as dt_util from . import FritzDeviceSwitchMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup( - hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, ) -> None: """Test setup of platform.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SWITCH]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_humidity") - assert state is None - - sensors = ( - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_temperature", - "1.23", - f"{CONF_FAKE_NAME} Temperature", - UnitOfTemperature.CELSIUS, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_power", - "5.678", - f"{CONF_FAKE_NAME} Power", - UnitOfPower.WATT, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_energy", - "1.234", - f"{CONF_FAKE_NAME} Energy", - UnitOfEnergy.KILO_WATT_HOUR, - SensorStateClass.TOTAL_INCREASING, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_voltage", - "230.0", - f"{CONF_FAKE_NAME} Voltage", - UnitOfElectricPotential.VOLT, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current", - "0.025", - f"{CONF_FAKE_NAME} Current", - UnitOfElectricCurrent.AMPERE, - SensorStateClass.MEASUREMENT, - None, - ], - ) - - for sensor in sensors: - state = hass.states.get(sensor[0]) - assert state - assert state.state == sensor[1] - assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] - assert state.attributes[ATTR_STATE_CLASS] == sensor[4] - assert state.attributes[ATTR_STATE_CLASS] == sensor[4] - entry = entity_registry.async_get(sensor[0]) - assert entry - assert entry.entity_category is sensor[5] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -133,7 +65,7 @@ async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device off.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -149,7 +81,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: device = FritzDeviceSwitchMock() device.lock = True - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -173,7 +105,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) assert fritz().update_devices.call_count == 1 @@ -191,9 +123,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceSwitchMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -211,7 +144,7 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No device.voltage = 0 device.energy = 0 device.power = 0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -223,7 +156,7 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/frontend/test_storage.py b/tests/components/frontend/test_storage.py index ce7f7aeb4a1..360ca151551 100644 --- a/tests/components/frontend/test_storage.py +++ b/tests/components/frontend/test_storage.py @@ -13,9 +13,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def setup_frontend(hass: HomeAssistant) -> None: +async def setup_frontend(hass: HomeAssistant) -> None: """Fixture to setup the frontend.""" - hass.loop.run_until_complete(async_setup_component(hass, "frontend", {})) + await async_setup_component(hass, "frontend", {}) async def test_get_user_data_empty( diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 7e2e92f025b..65be83bad20 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -1119,6 +1119,52 @@ async def test_precision(hass: HomeAssistant) -> None: assert state.attributes.get("target_temp_step") == 0.1 +@pytest.fixture( + params=[ + HVACMode.HEAT, + HVACMode.COOL, + ] +) +async def setup_comp_10(hass: HomeAssistant, request: pytest.FixtureRequest) -> None: + """Initialize components.""" + assert await async_setup_component( + hass, + CLIMATE_DOMAIN, + { + "climate": { + "platform": "generic_thermostat", + "name": "test", + "cold_tolerance": 0, + "hot_tolerance": 0, + "target_temp": 25, + "heater": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "initial_hvac_mode": request.param, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("setup_comp_10") +async def test_zero_tolerances(hass: HomeAssistant) -> None: + """Test that having a zero tolerance doesn't cause the switch to flip-flop.""" + + # if the switch is off, it should remain off + calls = _setup_switch(hass, False) + _setup_sensor(hass, 25) + await hass.async_block_till_done() + await common.async_set_temperature(hass, 25) + assert len(calls) == 0 + + # if the switch is on, it should turn off + calls = _setup_switch(hass, True) + _setup_sensor(hass, 25) + await hass.async_block_till_done() + await common.async_set_temperature(hass, 25) + assert len(calls) == 1 + + async def test_custom_setup_params(hass: HomeAssistant) -> None: """Test the setup with custom parameters.""" result = await async_setup_component( diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index 7673f357a08..0a9ad8a5b16 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -29,22 +29,20 @@ def calls(hass: HomeAssistant) -> list[ServiceCall]: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": 32.880837, - "longitude": -117.237561, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + } + }, ) diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 3d10e753714..720c0176850 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1451,6 +1451,14 @@ async def test_working_location_ignored( assert state.attributes.get("message") == expected_event_message +@pytest.mark.parametrize( + ("event_type", "expected_event_message"), + [ + ("workingLocation", "Test All Day Event"), + ("birthday", None), + ("default", None), + ], +) @pytest.mark.parametrize("calendar_is_primary", [True]) async def test_working_location_entity( hass: HomeAssistant, @@ -1458,12 +1466,14 @@ async def test_working_location_entity( entity_registry: er.EntityRegistry, mock_events_list_items: Callable[[list[dict[str, Any]]], None], component_setup: ComponentSetup, + event_type: str, + expected_event_message: str | None, ) -> None: """Test that working location events are registered under a disabled by default entity.""" event = { **TEST_EVENT, **upcoming(), - "eventType": "workingLocation", + "eventType": event_type, } mock_events_list_items([event]) assert await component_setup() @@ -1484,7 +1494,7 @@ async def test_working_location_entity( state = hass.states.get("calendar.working_location") assert state assert state.name == "Working location" - assert state.attributes.get("message") == "Test All Day Event" + assert state.attributes.get("message") == expected_event_message @pytest.mark.parametrize("calendar_is_primary", [False]) @@ -1506,3 +1516,49 @@ async def test_no_working_location_entity( entity_entry = entity_registry.async_get("calendar.working_location") assert not entity_entry + + +@pytest.mark.parametrize( + ("event_type", "expected_event_message"), + [ + ("workingLocation", None), + ("birthday", "Test All Day Event"), + ("default", None), + ], +) +@pytest.mark.parametrize("calendar_is_primary", [True]) +async def test_birthday_entity( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + mock_events_list_items: Callable[[list[dict[str, Any]]], None], + component_setup: ComponentSetup, + event_type: str, + expected_event_message: str | None, +) -> None: + """Test that birthday events appear only on the birthdays calendar.""" + event = { + **TEST_EVENT, + **upcoming(), + "eventType": event_type, + } + mock_events_list_items([event]) + assert await component_setup() + + entity_entry = entity_registry.async_get("calendar.birthdays") + assert entity_entry + assert entity_entry.disabled_by is None # Enabled by default + + entity_registry.async_update_entity( + entity_id="calendar.birthdays", disabled_by=None + ) + async_fire_time_changed( + hass, + dt_util.utcnow() + datetime.timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + state = hass.states.get("calendar.birthdays") + assert state + assert state.name == "Birthdays" + assert state.attributes.get("message") == expected_event_message diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 035a8d151c4..26541d33613 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -1,6 +1,5 @@ """The tests for the Google Assistant component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json from unittest.mock import patch @@ -38,32 +37,28 @@ def auth_header(hass_access_token: str) -> dict[str, str]: @pytest.fixture -def assistant_client( - event_loop: AbstractEventLoop, +async def assistant_client( hass: core.HomeAssistant, hass_client_no_auth: ClientSessionGenerator, ) -> TestClient: """Create web client for the Google Assistant API.""" - loop = event_loop - loop.run_until_complete( - setup.async_setup_component( - hass, - "google_assistant", - { - "google_assistant": { - "project_id": PROJECT_ID, - "entity_config": { - "light.ceiling_lights": { - "aliases": ["top lights", "ceiling lights"], - "name": "Roof Lights", - } - }, - } - }, - ) + await setup.async_setup_component( + hass, + "google_assistant", + { + "google_assistant": { + "project_id": PROJECT_ID, + "entity_config": { + "light.ceiling_lights": { + "aliases": ["top lights", "ceiling lights"], + "name": "Roof Lights", + } + }, + } + }, ) - return loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() @pytest.fixture(autouse=True) @@ -87,16 +82,12 @@ async def wanted_platforms_only() -> None: @pytest.fixture -def hass_fixture( - event_loop: AbstractEventLoop, hass: core.HomeAssistant -) -> core.HomeAssistant: +async def hass_fixture(hass: core.HomeAssistant) -> core.HomeAssistant: """Set up a Home Assistant instance for these tests.""" - loop = event_loop - # We need to do this to get access to homeassistant/turn_(on,off) - loop.run_until_complete(setup.async_setup_component(hass, core.DOMAIN, {})) + await setup.async_setup_component(hass, core.DOMAIN, {}) - loop.run_until_complete(setup.async_setup_component(hass, "demo", {})) + await setup.async_setup_component(hass, "demo", {}) return hass diff --git a/tests/components/google_generative_ai_conversation/__init__.py b/tests/components/google_generative_ai_conversation/__init__.py index 6e2d37b035b..fbf9ee545db 100644 --- a/tests/components/google_generative_ai_conversation/__init__.py +++ b/tests/components/google_generative_ai_conversation/__init__.py @@ -3,12 +3,12 @@ from unittest.mock import Mock from google.genai.errors import ClientError -import requests +import httpx CLIENT_ERROR_500 = ClientError( 500, Mock( - __class__=requests.Response, + __class__=httpx.Response, json=Mock( return_value={ "message": "Internal Server Error", @@ -20,7 +20,7 @@ CLIENT_ERROR_500 = ClientError( CLIENT_ERROR_API_KEY_INVALID = ClientError( 400, Mock( - __class__=requests.Response, + __class__=httpx.Response, json=Mock( return_value={ "message": "'reason': API_KEY_INVALID", diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 2bc81b10ce4..6ec147da2ab 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -4,6 +4,9 @@ from unittest.mock import Mock, patch import pytest +from homeassistant.components.google_generative_ai_conversation.conversation import ( + CONF_USE_GOOGLE_SEARCH_TOOL, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant @@ -41,6 +44,23 @@ async def mock_config_entry_with_assist( return mock_config_entry +@pytest.fixture +async def mock_config_entry_with_google_search( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Mock a config entry with assist.""" + with patch("google.genai.models.AsyncModels.get"): + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + ) + await hass.async_block_till_done() + return mock_config_entry + + @pytest.fixture async def mock_init_component( hass: HomeAssistant, mock_config_entry: ConfigEntry diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index c840f7da324..2376bf51cdc 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', @@ -25,7 +25,9 @@ tuple( ), dict({ - 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), + 'message': list([ + Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), + ]), }), ), ]) @@ -37,7 +39,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=None)], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=None)], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', @@ -56,7 +58,42 @@ tuple( ), dict({ - 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), + 'message': list([ + Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), + ]), + }), + ), + ]) +# --- +# name: test_use_google_search + list([ + tuple( + '', + tuple( + ), + dict({ + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None), Tool(function_declarations=None, retrieval=None, google_search=GoogleSearch(), google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'history': list([ + ]), + 'model': 'models/gemini-2.0-flash', + }), + ), + tuple( + '().send_message', + tuple( + ), + dict({ + 'message': 'Please call the test function', + }), + ), + tuple( + '().send_message', + tuple( + ), + dict({ + 'message': list([ + Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), + ]), }), ), ]) diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 30c9d6c46e6..8fda02b335d 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -21,12 +21,14 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant @@ -37,9 +39,8 @@ from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID from tests.common import MockConfigEntry -@pytest.fixture -def mock_models(): - """Mock the model list API.""" +def get_models_pager(): + """Return a generator that yields the models.""" model_20_flash = Mock( display_name="Gemini 2.0 Flash", supported_actions=["generateContent"], @@ -70,11 +71,7 @@ def mock_models(): yield model_15_pro yield model_10_pro - with patch( - "google.genai.models.AsyncModels.list", - return_value=models_pager(), - ): - yield + return models_pager() async def test_form(hass: HomeAssistant) -> None: @@ -117,8 +114,13 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +def will_options_be_rendered_again(current_options, new_options) -> bool: + """Determine if options will be rendered again.""" + return current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED) + + @pytest.mark.parametrize( - ("current_options", "new_options", "expected_options"), + ("current_options", "new_options", "expected_options", "errors"), [ ( { @@ -143,7 +145,9 @@ async def test_form(hass: HomeAssistant) -> None: CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, }, + None, ), ( { @@ -154,6 +158,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_USE_GOOGLE_SEARCH_TOOL: True, }, { CONF_RECOMMENDED: True, @@ -165,6 +170,98 @@ async def test_form(hass: HomeAssistant) -> None: CONF_LLM_HASS_API: "assist", CONF_PROMPT: "", }, + None, + ), + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + None, + ), + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: "assist", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + {CONF_USE_GOOGLE_SEARCH_TOOL: "invalid_google_search_option"}, ), ], ) @@ -172,10 +269,10 @@ async def test_form(hass: HomeAssistant) -> None: async def test_options_switching( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_models, current_options, new_options, expected_options, + errors, ) -> None: """Test the options form.""" with patch("google.genai.models.AsyncModels.get"): @@ -183,24 +280,42 @@ async def test_options_switching( mock_config_entry, options=current_options ) await hass.async_block_till_done() - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id - ) - if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): - options_flow = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - **current_options, - CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], - }, + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + if will_options_be_rendered_again(current_options, new_options): + retry_options = { + **current_options, + CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], + } + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options_flow = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + retry_options, + ) + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + new_options, ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - new_options, - ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == expected_options + if errors is None: + assert options["type"] is FlowResultType.CREATE_ENTRY + assert options["data"] == expected_options + + else: + assert options["type"] is FlowResultType.FORM + assert options.get("errors", None) == errors @pytest.mark.parametrize( diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 64f71c18bf2..9c4ecc4f9a4 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -10,7 +10,7 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation -from homeassistant.components.conversation import trace +from homeassistant.components.conversation import UserContent, async_get_chat_log, trace from homeassistant.components.google_generative_ai_conversation.conversation import ( _escape_decode, _format_schema, @@ -18,7 +18,7 @@ from homeassistant.components.google_generative_ai_conversation.conversation imp from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import chat_session, intent, llm from . import CLIENT_ERROR_500 @@ -104,28 +104,24 @@ async def test_function_call( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_create.mock_calls[2][2]["message"] - assert mock_tool_call.model_dump() == { - "parts": [ - { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "result": "Test response", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, + mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] + assert len(mock_tool_response_parts) == 1 + assert mock_tool_response_parts[0].model_dump() == { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, + "function_response": { + "id": None, + "name": "test_tool", + "response": { + "result": "Test response", }, - ], - "role": None, + }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, } mock_tool.async_call.assert_awaited_once_with( @@ -156,8 +152,10 @@ async def test_function_call( trace_events = last_trace.get("events", []) assert [event["event_type"] for event in trace_events] == [ trace.ConversationTraceEventType.ASYNC_PROCESS, - trace.ConversationTraceEventType.AGENT_DETAIL, + trace.ConversationTraceEventType.AGENT_DETAIL, # prompt and tools + trace.ConversationTraceEventType.AGENT_DETAIL, # stats for response trace.ConversationTraceEventType.TOOL_CALL, + trace.ConversationTraceEventType.AGENT_DETAIL, # stats for response ] # AGENT_DETAIL event contains the raw prompt passed to the model detail_event = trace_events[1] @@ -166,6 +164,79 @@ async def test_function_call( p["tool_name"] for p in detail_event["data"]["messages"][2]["tool_calls"] ] == ["test_tool"] + detail_event = trace_events[2] + assert set(detail_event["data"]["stats"].keys()) == { + "input_tokens", + "cached_input_tokens", + "output_tokens", + } + + +@patch( + "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" +) +@pytest.mark.usefixtures("mock_init_component") +@pytest.mark.usefixtures("mock_ulid_tools") +async def test_use_google_search( + mock_get_tools, + hass: HomeAssistant, + mock_config_entry_with_google_search: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test function calling.""" + agent_id = "conversation.google_generative_ai_conversation" + context = Context() + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + { + vol.Optional("param1", description="Test parameters"): [ + vol.All(str, vol.Lower) + ], + vol.Optional("param2"): vol.Any(float, int), + vol.Optional("param3"): dict, + } + ) + + mock_get_tools.return_value = [mock_tool] + + with patch("google.genai.chats.AsyncChats.create") as mock_create: + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + mock_part = Mock() + mock_part.text = "" + mock_part.function_call = FunctionCall( + name="test_tool", + args={ + "param1": ["test_value", "param1\\'s value"], + "param2": 2.7, + }, + ) + + def tool_call( + hass: HomeAssistant, tool_input: llm.ToolInput, tool_context: llm.LLMContext + ) -> dict[str, Any]: + mock_part.function_call = None + mock_part.text = "Hi there!" + return {"result": "Test response"} + + mock_tool.async_call.side_effect = tool_call + chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] + await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + device_id="test_device", + ) + + assert [tuple(mock_call) for mock_call in mock_create.mock_calls] == snapshot + @patch( "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" @@ -217,28 +288,24 @@ async def test_function_call_without_parameters( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_create.mock_calls[2][2]["message"] - assert mock_tool_call.model_dump() == { - "parts": [ - { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "result": "Test response", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, + mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] + assert len(mock_tool_response_parts) == 1 + assert mock_tool_response_parts[0].model_dump() == { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, + "function_response": { + "id": None, + "name": "test_tool", + "response": { + "result": "Test response", }, - ], - "role": None, + }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, } mock_tool.async_call.assert_awaited_once_with( @@ -315,29 +382,25 @@ async def test_function_exception( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_create.mock_calls[2][2]["message"] - assert mock_tool_call.model_dump() == { - "parts": [ - { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "error": "HomeAssistantError", - "error_text": "Test tool exception", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, + mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] + assert len(mock_tool_response_parts) == 1 + assert mock_tool_response_parts[0].model_dump() == { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, + "function_response": { + "id": None, + "name": "test_tool", + "response": { + "error": "HomeAssistantError", + "error_text": "Test tool exception", }, - ], - "role": None, + }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, } mock_tool.async_call.assert_awaited_once_with( hass, @@ -618,3 +681,82 @@ async def test_escape_decode() -> None: async def test_format_schema(openapi, genai_schema) -> None: """Test _format_schema.""" assert _format_schema(openapi) == genai_schema + + +@pytest.mark.usefixtures("mock_init_component") +async def test_empty_content_in_chat_history( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Tests that in case of an empty entry in the chat history the google API will receive an injected space sign instead.""" + with ( + patch("google.genai.chats.AsyncChats.create") as mock_create, + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session) as chat_log, + ): + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + + # Chat preparation with two inputs, one being an empty string + first_input = "First request" + second_input = "" + chat_log.async_add_user_content(UserContent(first_input)) + chat_log.async_add_user_content(UserContent(second_input)) + + await conversation.async_converse( + hass, + "Second request", + session.conversation_id, + Context(), + agent_id="conversation.google_generative_ai_conversation", + ) + + _, kwargs = mock_create.call_args + actual_history = kwargs.get("history") + + assert actual_history[0].parts[0].text == first_input + assert actual_history[1].parts[0].text == " " + + +@pytest.mark.usefixtures("mock_init_component") +async def test_history_always_user_first_turn( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test that the user is always first in the chat history.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session) as chat_log, + ): + chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id="conversation.google_generative_ai_conversation", + content="Garage door left open, do you want to close it?", + ) + ) + + with patch("google.genai.chats.AsyncChats.create") as mock_create: + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + chat_response.candidates = [Mock(content=Mock(parts=[]))] + + await conversation.async_converse( + hass, + "hello", + chat_log.conversation_id, + Context(), + agent_id="conversation.google_generative_ai_conversation", + ) + + _, kwargs = mock_create.call_args + actual_history = kwargs.get("history") + + assert actual_history[0].parts[0].text == " " + assert actual_history[0].role == "user" + assert ( + actual_history[1].parts[0].text + == "Garage door left open, do you want to close it?" + ) + assert actual_history[1].role == "model" diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index 836641cb2ab..c3fa3ae24c7 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -16,10 +16,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Quiet', + 'friendly_name': 'fake-device-1 Quiet mode', }), 'context': , - 'entity_id': 'switch.fake_device_1_quiet', + 'entity_id': 'switch.fake_device_1_quiet_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -40,10 +40,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 XFan', + 'friendly_name': 'fake-device-1 Xtra fan', }), 'context': , - 'entity_id': 'switch.fake_device_1_xfan', + 'entity_id': 'switch.fake_device_1_xtra_fan', 'last_changed': , 'last_reported': , 'last_updated': , @@ -109,7 +109,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.fake_device_1_quiet', + 'entity_id': 'switch.fake_device_1_quiet_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -121,7 +121,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Quiet', + 'original_name': 'Quiet mode', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, @@ -173,7 +173,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.fake_device_1_xfan', + 'entity_id': 'switch.fake_device_1_xtra_fan', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -185,7 +185,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'XFan', + 'original_name': 'Xtra fan', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index e9491796bdf..331b6dfa4a6 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -22,11 +22,11 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -ENTITY_ID_LIGHT_PANEL = f"{SWITCH_DOMAIN}.fake_device_1_panel_light" +ENTITY_ID_PANEL_LIGHT = f"{SWITCH_DOMAIN}.fake_device_1_panel_light" ENTITY_ID_HEALTH_MODE = f"{SWITCH_DOMAIN}.fake_device_1_health_mode" -ENTITY_ID_QUIET = f"{SWITCH_DOMAIN}.fake_device_1_quiet" +ENTITY_ID_QUIET_MODE = f"{SWITCH_DOMAIN}.fake_device_1_quiet_mode" ENTITY_ID_FRESH_AIR = f"{SWITCH_DOMAIN}.fake_device_1_fresh_air" -ENTITY_ID_XFAN = f"{SWITCH_DOMAIN}.fake_device_1_xfan" +ENTITY_ID_XTRA_FAN = f"{SWITCH_DOMAIN}.fake_device_1_xtra_fan" async def async_setup_gree(hass: HomeAssistant) -> MockConfigEntry: @@ -54,11 +54,11 @@ async def test_registry_settings( @pytest.mark.parametrize( "entity", [ - ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_PANEL_LIGHT, ENTITY_ID_HEALTH_MODE, - ENTITY_ID_QUIET, + ENTITY_ID_QUIET_MODE, ENTITY_ID_FRESH_AIR, - ENTITY_ID_XFAN, + ENTITY_ID_XTRA_FAN, ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -81,11 +81,11 @@ async def test_send_switch_on(hass: HomeAssistant, entity: str) -> None: @pytest.mark.parametrize( "entity", [ - ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_PANEL_LIGHT, ENTITY_ID_HEALTH_MODE, - ENTITY_ID_QUIET, + ENTITY_ID_QUIET_MODE, ENTITY_ID_FRESH_AIR, - ENTITY_ID_XFAN, + ENTITY_ID_XTRA_FAN, ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -112,11 +112,11 @@ async def test_send_switch_on_device_timeout( @pytest.mark.parametrize( "entity", [ - ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_PANEL_LIGHT, ENTITY_ID_HEALTH_MODE, - ENTITY_ID_QUIET, + ENTITY_ID_QUIET_MODE, ENTITY_ID_FRESH_AIR, - ENTITY_ID_XFAN, + ENTITY_ID_XTRA_FAN, ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -139,11 +139,11 @@ async def test_send_switch_off(hass: HomeAssistant, entity: str) -> None: @pytest.mark.parametrize( "entity", [ - ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_PANEL_LIGHT, ENTITY_ID_HEALTH_MODE, - ENTITY_ID_QUIET, + ENTITY_ID_QUIET_MODE, ENTITY_ID_FRESH_AIR, - ENTITY_ID_XFAN, + ENTITY_ID_XTRA_FAN, ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 258346b9ca7..774593fa0f6 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -60,6 +60,7 @@ from homeassistant.components.habitica.const import ( SERVICE_ACCEPT_QUEST, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_DAILY, SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_CREATE_TODO, @@ -1012,7 +1013,12 @@ async def test_update_task_exceptions( ) @pytest.mark.parametrize( "service", - [SERVICE_CREATE_REWARD, SERVICE_CREATE_HABIT, SERVICE_CREATE_TODO], + [ + SERVICE_CREATE_DAILY, + SERVICE_CREATE_HABIT, + SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, + ], ) @pytest.mark.usefixtures("habitica") async def test_create_task_exceptions( @@ -1837,6 +1843,182 @@ async def test_update_daily( habitica.update_task.assert_awaited_with(UUID(task_id), call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.DAILY, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.DAILY, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + type=TaskType.DAILY, + text="TITLE", + checklist=[ + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_PRIORITY: "trivial", + }, + Task(type=TaskType.DAILY, text="TITLE", priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_START_DATE: "2025-03-05", + }, + Task(type=TaskType.DAILY, text="TITLE", startDate=datetime(2025, 3, 5)), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "weekly", + }, + Task(type=TaskType.DAILY, text="TITLE", frequency=Frequency.WEEKLY), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_INTERVAL: 5, + }, + Task(type=TaskType.DAILY, text="TITLE", everyX=5), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT: ["m", "t", "w", "th"], + }, + Task( + type=TaskType.DAILY, + text="TITLE", + frequency=Frequency.WEEKLY, + repeat=Repeat(m=True, t=True, w=True, th=True), + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_month", + }, + Task( + type=TaskType.DAILY, + text="TITLE", + frequency=Frequency.MONTHLY, + daysOfMonth=[25], + weeksOfMonth=[], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_week", + }, + Task( + type=TaskType.DAILY, + text="TITLE", + frequency=Frequency.MONTHLY, + daysOfMonth=[], + weeksOfMonth=[3], + repeat=Repeat( + m=False, t=True, w=False, th=False, f=False, s=False, su=False + ), + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_REMINDER: ["10:00"], + }, + Task( + type=TaskType.DAILY, + text="TITLE", + reminders=[ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 10, 0, tzinfo=UTC), + startDate=None, + ) + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_REMOVE_REMINDER: ["10:00"], + }, + Task(type=TaskType.DAILY, text="TITLE", reminders=[]), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_CLEAR_REMINDER: True, + }, + Task(type=TaskType.DAILY, text="TITLE", reminders=[]), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_STREAK: 10, + }, + Task(type=TaskType.DAILY, text="TITLE", streak=10), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.DAILY, text="TITLE", alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +@freeze_time("2025-02-25T22:00:00.000Z") +async def test_create_daily( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create daily action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_DAILY, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + @pytest.mark.parametrize( "service_data", [ diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index c9fbf1a7c56..0c6e2158f3b 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -46,7 +46,7 @@ def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]: @pytest.fixture -def hassio_stubs( +async def hassio_stubs( hassio_env: None, hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -75,27 +75,27 @@ def hassio_stubs( "homeassistant.components.hassio.issues.SupervisorIssues.setup", ), ): - hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) + await async_setup_component(hass, "hassio", {}) return hass_api.call_args[0][1] @pytest.fixture -def hassio_client( +async def hassio_client( hassio_stubs: RefreshToken, hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> TestClient: """Return a Hass.io HTTP client.""" - return hass.loop.run_until_complete(hass_client()) + return await hass_client() @pytest.fixture -def hassio_noauth_client( +async def hassio_noauth_client( hassio_stubs: RefreshToken, hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, ) -> TestClient: """Return a Hass.io HTTP client without auth.""" - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return await aiohttp_client(hass.http.app) @pytest.fixture diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index e00994b355a..af951fe8aa1 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -3,6 +3,7 @@ from collections.abc import ( AsyncGenerator, AsyncIterator, + Buffer, Callable, Coroutine, Generator, @@ -13,7 +14,7 @@ from datetime import datetime from io import StringIO import os from pathlib import PurePath -from typing import Any +from typing import Any, cast from unittest.mock import ANY, AsyncMock, Mock, patch from uuid import UUID @@ -341,7 +342,7 @@ def mock_backup_agent( async def delete_backup(backup_id: str, **kwargs: Any) -> None: """Mock delete.""" - get_backup(backup_id) + await get_backup(backup_id) async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: """Mock download.""" @@ -349,7 +350,7 @@ def mock_backup_agent( async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup: """Get a backup.""" - backup = next((b for b in backups if b.backup_id == backup_id), None) + backup = next((b for b in _backups if b.backup_id == backup_id), None) if backup is None: raise BackupNotFound return backup @@ -361,15 +362,15 @@ def mock_backup_agent( **kwargs: Any, ) -> None: """Upload a backup.""" - backups.append(backup) + _backups.append(backup) backup_stream = await open_stream() backup_data = bytearray() async for chunk in backup_stream: backup_data += chunk backups_data[backup.backup_id] = backup_data - backups = backups or [] - backups_data: dict[str, bytes] = {} + _backups = backups or [] + backups_data: dict[str, Buffer] = {} mock_agent = Mock(spec=BackupAgent) mock_agent.domain = domain mock_agent.name = name @@ -401,7 +402,7 @@ async def _setup_backup_platform( platform: BackupAgentPlatformProtocol, ) -> None: """Set up a mock domain.""" - mock_platform(hass, f"{domain}.backup", platform) + mock_platform(hass, f"{domain}.backup", cast(Mock, platform)) assert await async_setup_component(hass, domain, {}) await hass.async_block_till_done() @@ -423,7 +424,7 @@ async def _setup_backup_platform( name="test", read_only=False, state=supervisor_mounts.MountState.ACTIVE, - user_path="test", + user_path=PurePath("test"), usage=supervisor_mounts.MountUsage.BACKUP, server="test", type=supervisor_mounts.MountType.CIFS, @@ -441,7 +442,7 @@ async def _setup_backup_platform( name="test", read_only=False, state=supervisor_mounts.MountState.ACTIVE, - user_path="test", + user_path=PurePath("test"), usage=supervisor_mounts.MountUsage.MEDIA, server="test", type=supervisor_mounts.MountType.CIFS, @@ -854,7 +855,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( "with_automatic_settings": False, }, filename=PurePath("Test_2025-01-30_05.42_12345678.tar"), - folders={"ssl"}, + folders={supervisor_backups.Folder("ssl")}, homeassistant_exclude_database=False, homeassistant=True, location=[LOCATION_LOCAL_STORAGE], @@ -877,7 +878,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( ), ( {"include_all_addons": True}, - replace(DEFAULT_BACKUP_OPTIONS, addons="ALL"), + replace(DEFAULT_BACKUP_OPTIONS, addons=supervisor_backups.AddonSet("ALL")), ), ( {"include_database": False}, @@ -885,7 +886,14 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( ), ( {"include_folders": ["media", "share"]}, - replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share", "ssl"}), + replace( + DEFAULT_BACKUP_OPTIONS, + folders={ + supervisor_backups.Folder("media"), + supervisor_backups.Folder("share"), + supervisor_backups.Folder("ssl"), + }, + ), ), ( { @@ -895,7 +903,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( }, replace( DEFAULT_BACKUP_OPTIONS, - folders={"media"}, + folders={supervisor_backups.Folder("media")}, homeassistant=False, homeassistant_exclude_database=True, ), @@ -1251,11 +1259,11 @@ async def test_reader_writer_create_per_agent_encryption( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, - commands: dict[str, Any], + commands: list[dict[str, Any]], password: str | None, agent_ids: list[str], password_sent_to_supervisor: str | None, - create_locations: list[str | None], + create_locations: list[str], create_protected: bool, upload_locations: list[str | None], ) -> None: @@ -1270,7 +1278,7 @@ async def test_reader_writer_create_per_agent_encryption( name=f"share{i}", read_only=False, state=supervisor_mounts.MountState.ACTIVE, - user_path=f"share{i}", + user_path=PurePath(f"share{i}"), usage=supervisor_mounts.MountUsage.BACKUP, server=f"share{i}", type=supervisor_mounts.MountType.CIFS, @@ -1996,7 +2004,7 @@ async def test_reader_writer_restore_remote_backup( homeassistant_version="2024.12.0", name="Test", protected=False, - size=0.0, + size=0, ) remote_agent = mock_backup_agent("remote", backups=[test_backup]) await _setup_backup_platform( @@ -2626,7 +2634,7 @@ async def test_config_load_config_info( freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, hass_storage: dict[str, Any], - storage_data: dict[str, Any] | None, + storage_data: dict[str, Any], ) -> None: """Test loading stored backup config and reading it via config/info.""" client = await hass_ws_client(hass) diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index b695cc1794a..6334fb096a2 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -42,6 +42,7 @@ def mock_all( aioclient_mock: AiohttpClientMocker, supervisor_is_connected: AsyncMock, resolution_info: AsyncMock, + addon_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index cb4313bbd10..edc128f2f78 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -37,10 +37,14 @@ class MockHeos(Heos): self.play_preset_station: AsyncMock = AsyncMock() self.play_url: AsyncMock = AsyncMock() self.player_clear_queue: AsyncMock = AsyncMock() + self.player_get_queue: AsyncMock = AsyncMock() self.player_get_quick_selects: AsyncMock = AsyncMock() + self.player_move_queue_item: AsyncMock = AsyncMock() self.player_play_next: AsyncMock = AsyncMock() self.player_play_previous: AsyncMock = AsyncMock() + self.player_play_queue: AsyncMock = AsyncMock() self.player_play_quick_select: AsyncMock = AsyncMock() + self.player_remove_from_queue: AsyncMock = AsyncMock() self.player_set_mute: AsyncMock = AsyncMock() self.player_set_play_mode: AsyncMock = AsyncMock() self.player_set_play_state: AsyncMock = AsyncMock() diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 5d06d1812ea..835e4436398 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -20,6 +20,7 @@ from pyheos import ( NetworkType, PlayerUpdateResult, PlayState, + QueueItem, RepeatType, const, ) @@ -359,3 +360,28 @@ def change_data_fixture() -> PlayerUpdateResult: def change_data_mapped_ids_fixture() -> PlayerUpdateResult: """Create player change data for testing.""" return PlayerUpdateResult(updated_player_ids={1: 101}) + + +@pytest.fixture(name="queue") +def queue_fixture() -> list[QueueItem]: + """Create a queue fixture.""" + return [ + QueueItem( + queue_id=1, + song="Espresso", + album="Espresso", + artist="Sabrina Carpenter", + image_url="http://resources.wimpmusic.com/images/e4f2d75f/a69e/4b8a/b800/e18546b1ad4c/640x640.jpg", + media_id="356276483", + album_id="356276481", + ), + QueueItem( + queue_id=2, + song="A Bar Song (Tipsy)", + album="A Bar Song (Tipsy)", + artist="Shaboozey", + image_url="http://resources.wimpmusic.com/images/d05b8da3/4fae/45ff/ac1b/7ab7caab3523/640x640.jpg", + media_id="354365598", + album_id="354365596", + ), + ] diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index d2cd8b3e12a..d366a7f6317 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -79,6 +79,16 @@ 'thumbnail': '', 'title': 'Pandora', }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'TuneIn', + }), dict({ 'can_expand': True, 'can_play': False, @@ -114,6 +124,16 @@ 'thumbnail': '', 'title': 'Pandora', }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'TuneIn', + }), ]), 'children_media_class': 'directory', 'media_class': 'directory', @@ -139,6 +159,32 @@ 'title': 'Music Sources', }) # --- +# name: test_get_queue + dict({ + 'media_player.test_player': dict({ + 'queue': list([ + dict({ + 'album': 'Espresso', + 'album_id': '356276481', + 'artist': 'Sabrina Carpenter', + 'image_url': 'http://resources.wimpmusic.com/images/e4f2d75f/a69e/4b8a/b800/e18546b1ad4c/640x640.jpg', + 'media_id': '356276483', + 'queue_id': 1, + 'song': 'Espresso', + }), + dict({ + 'album': 'A Bar Song (Tipsy)', + 'album_id': '354365596', + 'artist': 'Shaboozey', + 'image_url': 'http://resources.wimpmusic.com/images/d05b8da3/4fae/45ff/ac1b/7ab7caab3523/640x640.jpg', + 'media_id': '354365598', + 'queue_id': 2, + 'song': 'A Bar Song (Tipsy)', + }), + ]), + }), + }) +# --- # name: test_state_attributes StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index d5bc8cab488..30d17f4a8ca 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -15,6 +15,7 @@ from pyheos import ( MediaType as HeosMediaType, PlayerUpdateResult, PlayState, + QueueItem, RepeatType, SignalHeosEvent, SignalType, @@ -26,10 +27,15 @@ from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.heos.const import ( + ATTR_DESTINATION_POSITION, + ATTR_QUEUE_IDS, DOMAIN, + SERVICE_GET_QUEUE, SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_UP, + SERVICE_MOVE_QUEUE_ITEM, + SERVICE_REMOVE_FROM_QUEUE, ) from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, @@ -1319,6 +1325,51 @@ async def test_play_media_music_source_url( controller.play_url.assert_called_once() +async def test_play_media_queue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test the play media service with type queue.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "queue", + ATTR_MEDIA_CONTENT_ID: "2", + }, + blocking=True, + ) + controller.player_play_queue.assert_called_once_with(1, 2) + + +async def test_play_media_queue_invalid( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the play media service with an invalid queue id.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid queue id 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "queue", + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) + assert controller.player_play_queue.call_count == 0 + + async def test_browse_media_root( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -1696,3 +1747,84 @@ async def test_media_player_group_fails_wrong_integration( blocking=True, ) controller.set_group.assert_not_called() + + +async def test_get_queue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + queue: list[QueueItem], + snapshot: SnapshotAssertion, +) -> None: + """Test the get queue service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_get_queue.return_value = queue + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_QUEUE, + { + ATTR_ENTITY_ID: "media_player.test_player", + }, + blocking=True, + return_response=True, + ) + controller.player_get_queue.assert_called_once_with(1, None, None) + assert response == snapshot + + +async def test_remove_from_queue( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the get queue service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_FROM_QUEUE, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_QUEUE_IDS: [1, "2"]}, + blocking=True, + ) + controller.player_remove_from_queue.assert_called_once_with(1, [1, 2]) + + +async def test_move_queue_item_queue( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the move queue service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_MOVE_QUEUE_ITEM, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_QUEUE_IDS: [1, "2"], + ATTR_DESTINATION_POSITION: 10, + }, + blocking=True, + ) + controller.player_move_queue_item.assert_called_once_with(1, [1, 2], 10) + + +async def test_move_queue_item_queue_error_raises( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test move queue raises error when failed.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_move_queue_item.side_effect = HeosError("error") + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to move queue item: error"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_MOVE_QUEUE_ITEM, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_QUEUE_IDS: [1, "2"], + ATTR_DESTINATION_POSITION: 10, + }, + blocking=True, + ) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index c0caf2b2bdd..21cd236b1a8 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -473,20 +473,6 @@ def mock_client_with_exception( return mock -@pytest.fixture(name="appliance_ha_id") -def mock_appliance_ha_id( - appliances: list[HomeAppliance], request: pytest.FixtureRequest -) -> str: - """Fixture to get the ha_id of an appliance.""" - appliance_type = "Washer" - if hasattr(request, "param") and request.param: - appliance_type = request.param - for appliance in appliances: - if appliance.type == appliance_type: - return appliance.ha_id - raise ValueError(f"Appliance {appliance_type} not found") - - @pytest.fixture(name="appliances") def mock_appliances( appliances_data: str, request: pytest.FixtureRequest diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_services.ambr similarity index 96% rename from tests/components/home_connect/snapshots/test_init.ambr rename to tests/components/home_connect/snapshots/test_services.ambr index 709621aaefb..610e9fa1248 100644 --- a/tests/components/home_connect/snapshots/test_init.ambr +++ b/tests/components/home_connect/snapshots/test_services.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_set_program_and_options[service_call0-set_selected_program] +# name: test_set_program_and_options[service_call0-set_selected_program-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -18,7 +18,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call1-start_program] +# name: test_set_program_and_options[service_call1-start_program-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -37,7 +37,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call2-set_active_program_options] +# name: test_set_program_and_options[service_call2-set_active_program_options-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -57,7 +57,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call3-set_selected_program_options] +# name: test_set_program_and_options[service_call3-set_selected_program_options-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index a06e386b84f..a245372c247 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -3,21 +3,22 @@ from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, MagicMock -from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType +from aiohomeconnect.model import ( + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + HomeAppliance, +) from aiohomeconnect.model.error import HomeConnectApiError import pytest -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( STATE_OFF, @@ -28,8 +29,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -52,8 +51,9 @@ async def test_binary_sensors( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -67,7 +67,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -75,7 +75,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -83,7 +83,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -92,7 +92,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -100,13 +100,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -123,7 +124,7 @@ async def test_connected_devices( get_status_original_mock = client.get_status def get_status_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -135,14 +136,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_status = get_status_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -150,23 +151,24 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -async def test_binary_sensors_entity_availabilty( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_binary_sensors_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if binary sensor entities availability are based on the appliance connection state.""" entity_ids = [ - "binary_sensor.washer_door", "binary_sensor.washer_remote_control", ] assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -181,7 +183,7 @@ async def test_binary_sensors_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -195,7 +197,7 @@ async def test_binary_sensors_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -210,56 +212,7 @@ async def test_binary_sensors_entity_availabilty( @pytest.mark.parametrize( - ("value", "expected"), - [ - (BSH_DOOR_STATE_CLOSED, "off"), - (BSH_DOOR_STATE_LOCKED, "off"), - (BSH_DOOR_STATE_OPEN, "on"), - ("", STATE_UNKNOWN), - ], -) -async def test_binary_sensors_door_states( - appliance_ha_id: str, - expected: str, - value: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Tests for Appliance door states.""" - entity_id = "binary_sensor.washer_door" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - await client.add_events( - [ - EventMessage( - appliance_ha_id, - EventType.STATUS, - ArrayOfEvents( - [ - Event( - key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, - raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, - timestamp=0, - level="", - handling="", - value=value, - ) - ], - ), - ) - ] - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, expected) - - -@pytest.mark.parametrize( - ("entity_id", "event_key", "event_value_update", "expected", "appliance_ha_id"), + ("entity_id", "event_key", "event_value_update", "expected", "appliance"), [ ( "binary_sensor.washer_remote_control", @@ -304,13 +257,13 @@ async def test_binary_sensors_door_states( "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_binary_sensors_functionality( entity_id: str, event_key: EventKey, event_value_update: str, - appliance_ha_id: str, + appliance: HomeAppliance, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, @@ -325,7 +278,7 @@ async def test_binary_sensors_functionality( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -346,13 +299,14 @@ async def test_binary_sensors_functionality( assert hass.states.is_state(entity_id, expected) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_sensor_functionality( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if the connected binary sensor reports the right values.""" entity_id = "binary_sensor.washer_connectivity" @@ -365,7 +319,7 @@ async def test_connected_sensor_functionality( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -378,7 +332,7 @@ async def test_connected_sensor_functionality( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -387,68 +341,3 @@ async def test_connected_sensor_functionality( await hass.async_block_till_done() assert hass.states.is_state(entity_id, STATE_ON) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_create_issue( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Test we create an issue when an automation or script is using a deprecated entity.""" - entity_id = "binary_sensor.washer_door" - issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": [ - { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) - - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py index 5af7e40ca43..f894494792d 100644 --- a/tests/components/home_connect/test_button.py +++ b/tests/components/home_connect/test_button.py @@ -4,7 +4,12 @@ from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import AsyncMock, MagicMock -from aiohomeconnect.model import ArrayOfCommands, CommandKey, EventMessage +from aiohomeconnect.model import ( + ArrayOfCommands, + CommandKey, + EventMessage, + HomeAppliance, +) from aiohomeconnect.model.command import Command from aiohomeconnect.model.error import HomeConnectApiError from aiohomeconnect.model.event import ArrayOfEvents, EventType @@ -40,8 +45,9 @@ async def test_buttons( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -55,7 +61,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -63,7 +69,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -71,7 +77,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -80,7 +86,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -88,13 +94,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -112,14 +119,14 @@ async def test_connected_devices( get_available_programs_mock = client.get_available_programs async def get_available_commands_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_available_commands_original_mock.side_effect(ha_id) async def get_available_programs_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -137,14 +144,14 @@ async def test_connected_devices( client.get_available_commands = get_available_commands_original_mock client.get_available_programs = get_available_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -152,19 +159,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -async def test_button_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_button_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if button entities availability are based on the appliance connection state.""" entity_ids = [ @@ -183,7 +191,7 @@ async def test_button_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -197,7 +205,7 @@ async def test_button_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -211,6 +219,7 @@ async def test_button_entity_availabilty( assert state.state != STATE_UNAVAILABLE +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("entity_id", "method_call", "expected_kwargs"), [ @@ -231,7 +240,7 @@ async def test_button_functionality( entity_id: str, method_call: str, expected_kwargs: dict[str, Any], - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if button entities availability are based on the appliance connection state.""" assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -248,7 +257,7 @@ async def test_button_functionality( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - getattr(client, method_call).assert_called_with(appliance_ha_id, **expected_kwargs) + getattr(client, method_call).assert_called_with(appliance.ha_id, **expected_kwargs) async def test_command_button_exception( diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 84bef94d658..d3b514bcc17 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -31,8 +31,17 @@ from homeassistant.components.home_connect.const import ( BSH_POWER_OFF, DOMAIN, ) +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.config_entries import ConfigEntries, ConfigEntryState -from homeassistant.const import EVENT_STATE_REPORTED, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_STATE_REPORTED, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import ( Event as HassEvent, EventStateReportedData, @@ -45,6 +54,14 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +INITIAL_FETCH_CLIENT_METHODS = [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", +] + @pytest.fixture def platforms() -> list[str]: @@ -98,30 +115,30 @@ async def test_coordinator_failure_refresh_and_stream( ) entity_id_1 = "binary_sensor.washer_remote_control" entity_id_2 = "binary_sensor.washer_remote_start" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, HA_DOMAIN, {}) await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED state = hass.states.get(entity_id_1) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE client.get_home_appliances.side_effect = HomeConnectError() # Force a coordinator refresh. await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id_1}, blocking=True ) await hass.async_block_till_done() state = hass.states.get(entity_id_1) assert state - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE # Test that the entity becomes available again after a successful update. @@ -137,16 +154,16 @@ async def test_coordinator_failure_refresh_and_stream( # Force a coordinator refresh. await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id_1}, blocking=True ) await hass.async_block_till_done() state = hass.states.get(entity_id_1) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE # Test that the event stream makes the entity go available too. @@ -160,16 +177,16 @@ async def test_coordinator_failure_refresh_and_stream( # Force a coordinator refresh await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id_1}, blocking=True ) await hass.async_block_till_done() state = hass.states.get(entity_id_1) assert state - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE # Now make the entity available again. client.get_home_appliances.side_effect = None @@ -199,21 +216,38 @@ async def test_coordinator_failure_refresh_and_stream( state = hass.states.get(entity_id_1) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "appliance", + ["Dishwasher"], + indirect=True, +) +async def test_coordinator_not_fetching_on_disconnected_appliance( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance: HomeAppliance, +) -> None: + """Test that the coordinator does not fetch anything on disconnected appliance.""" + appliance.connected = False + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for method in INITIAL_FETCH_CLIENT_METHODS: + assert getattr(client, method).call_count == 0 @pytest.mark.parametrize( "mock_method", - [ - "get_settings", - "get_status", - "get_all_programs", - "get_available_commands", - "get_available_program", - ], + INITIAL_FETCH_CLIENT_METHODS, ) async def test_coordinator_update_failing( mock_method: str, @@ -235,9 +269,9 @@ async def test_coordinator_update_failing( getattr(client, mock_method).assert_called() -@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) @pytest.mark.parametrize( - ("event_type", "event_key", "event_value", "entity_id"), + ("event_type", "event_key", "event_value", ATTR_ENTITY_ID), [ ( EventType.STATUS, @@ -269,7 +303,7 @@ async def test_event_listener( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, entity_registry: er.EntityRegistry, ) -> None: """Test that the event listener works.""" @@ -278,9 +312,9 @@ async def test_event_listener( assert config_entry.state == ConfigEntryState.LOADED state = hass.states.get(entity_id) - assert state + event_message = EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -300,7 +334,8 @@ async def test_event_listener( new_state = hass.states.get(entity_id) assert new_state - assert new_state.state != state.state + if state is not None: + assert new_state.state != state.state # Following, we are gonna check that the listeners are clean up correctly new_entity_id = entity_id + "_new" @@ -327,13 +362,14 @@ async def test_event_listener( listener.assert_called_once_with(new_entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def tests_receive_setting_and_status_for_first_time_at_events( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test that the event listener is capable of receiving settings and status for the first time.""" client.get_setting = AsyncMock(return_value=ArrayOfSettings([])) @@ -346,7 +382,7 @@ async def tests_receive_setting_and_status_for_first_time_at_events( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -362,7 +398,7 @@ async def tests_receive_setting_and_status_for_first_time_at_events( ), ), EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -519,7 +555,7 @@ async def test_devices_updated_on_refresh( return_value=ArrayOfHomeAppliances(appliances[:2]), ) - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, HA_DOMAIN, {}) assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -532,12 +568,44 @@ async def test_devices_updated_on_refresh( return_value=ArrayOfHomeAppliances(appliances[1:3]), ) await hass.services.async_call( - "homeassistant", - "update_entity", - {"entity_id": "switch.dishwasher_power"}, + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "switch.dishwasher_power"}, blocking=True, ) assert not device_registry.async_get_device({(DOMAIN, appliances[0].ha_id)}) for appliance in appliances[2:3]: assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_paired_disconnected_devices_not_fetching( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance: HomeAppliance, +) -> None: + """Test that Home Connect API is not fetched after pairing a disconnected device.""" + client.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances([])) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + appliance.connected = False + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + client.get_specific_appliance.assert_awaited_once_with(appliance.ha_id) + for method in INITIAL_FETCH_CLIENT_METHODS: + assert getattr(client, method).call_count == 0 diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index bad02888dbf..e91a01a907a 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -11,6 +11,7 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + HomeAppliance, Option, OptionKey, Program, @@ -67,7 +68,7 @@ def platforms() -> list[str]: ) @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "option_entity_id", "options_state_stage_1", "options_availability_stage_2", @@ -91,12 +92,12 @@ def platforms() -> list[str]: (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_program_options_retrieval( array_of_programs_program_arg: str, event_key: EventKey, - appliance_ha_id: str, + appliance: HomeAppliance, option_entity_id: dict[OptionKey, str], options_state_stage_1: list[tuple[str, bool | None]], options_availability_stage_2: list[bool], @@ -122,7 +123,7 @@ async def test_program_options_retrieval( ] async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return await original_get_all_programs_mock(ha_id) array_of_programs: ArrayOfPrograms = await original_get_all_programs_mock(ha_id) @@ -204,7 +205,7 @@ async def test_program_options_retrieval( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, data=ArrayOfEvents( [ @@ -235,6 +236,7 @@ async def test_program_options_retrieval( assert hass.states.is_state(entity_id, STATE_UNKNOWN) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("array_of_programs_program_arg", "event_key"), [ @@ -251,7 +253,7 @@ async def test_program_options_retrieval( async def test_no_options_retrieval_on_unknown_program( array_of_programs_program_arg: str, event_key: EventKey, - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -285,7 +287,7 @@ async def test_no_options_retrieval_on_unknown_program( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, data=ArrayOfEvents( [ @@ -315,7 +317,7 @@ async def test_no_options_retrieval_on_unknown_program( ], ) @pytest.mark.parametrize( - ("appliance_ha_id", "option_key", "option_entity_id"), + ("appliance", "option_key", "option_entity_id"), [ ( "Dishwasher", @@ -323,11 +325,11 @@ async def test_no_options_retrieval_on_unknown_program( "switch.dishwasher_half_load", ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_program_options_retrieval_after_appliance_connection( event_key: EventKey, - appliance_ha_id: str, + appliance: HomeAppliance, option_key: OptionKey, option_entity_id: str, hass: HomeAssistant, @@ -344,7 +346,7 @@ async def test_program_options_retrieval_after_appliance_connection( [ appliance for appliance in array_of_home_appliances.homeappliances - if appliance.ha_id != appliance_ha_id + if appliance.ha_id != appliance.ha_id ] ) @@ -367,7 +369,7 @@ async def test_program_options_retrieval_after_appliance_connection( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents( [ @@ -405,7 +407,7 @@ async def test_program_options_retrieval_after_appliance_connection( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, data=ArrayOfEvents( [ @@ -450,7 +452,6 @@ async def test_program_options_retrieval_after_appliance_connection( async def test_option_entity_functionality_exception( set_active_program_option_side_effect: HomeConnectError | None, set_selected_program_option_side_effect: HomeConnectError | None, - appliance_ha_id: str, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 291caeafd58..21bb0291e1a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -1,12 +1,11 @@ """Test the integration init functionality.""" from collections.abc import Awaitable, Callable -from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN -from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey +from aiohomeconnect.model import HomeAppliance, SettingKey, StatusKey from aiohomeconnect.model.error import ( HomeConnectError, TooManyRequestsError, @@ -14,7 +13,6 @@ from aiohomeconnect.model.error import ( ) import aiohttp import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.home_connect.const import DOMAIN @@ -25,9 +23,8 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir from script.hassfest.translations import RE_TRANSLATION_KEY from .conftest import ( @@ -40,157 +37,6 @@ from .conftest import ( from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - -DEPRECATED_SERVICE_KV_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "set_option_active", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_option_selected", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, -] - -SERVICE_KV_CALL_PARAMS = [ - *DEPRECATED_SERVICE_KV_CALL_PARAMS, - { - "domain": DOMAIN, - "service": "change_setting", - "service_data": { - "device_id": "DEVICE_ID", - "key": SettingKey.BSH_COMMON_CHILD_LOCK.value, - "value": True, - }, - "blocking": True, - }, -] - -SERVICE_COMMAND_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "pause_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "resume_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, -] - - -SERVICE_PROGRAM_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "select_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "start_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, -] - -SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_active_program_option", - "set_option_selected": "set_selected_program_option", - "change_setting": "set_setting", - "pause_program": "put_command", - "resume_program": "put_command", - "select_program": "set_selected_program", - "start_program": "start_program", -} - -SERVICE_VALIDATION_ERROR_MAPPING = { - "set_option_active": r"Error.*setting.*options.*active.*program.*", - "set_option_selected": r"Error.*setting.*options.*selected.*program.*", - "change_setting": r"Error.*assigning.*value.*setting.*", - "pause_program": r"Error.*executing.*command.*", - "resume_program": r"Error.*executing.*command.*", - "select_program": r"Error.*selecting.*program.*", - "start_program": r"Error.*starting.*program.*", -} - - -SERVICES_SET_PROGRAM_AND_OPTIONS = [ - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "selected_program", - "program": "dishcare_dishwasher_program_eco_50", - "b_s_h_common_option_start_in_relative": 1800, - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "active_program", - "program": "consumer_products_coffee_maker_program_beverage_coffee", - "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "active_program", - "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "selected_program", - "consumer_products_coffee_maker_option_fill_quantity": 35, - }, - "blocking": True, - }, -] async def test_entry_setup( @@ -401,197 +247,7 @@ async def test_client_rate_limit_error( asyncio_sleep_mock.assert_called_once_with(retry_after) -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_key_value_services( - service_call: dict[str, Any], - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, -) -> None: - """Create and test services.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_name = service_call["service"] - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - assert ( - getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 - ) - - -@pytest.mark.parametrize( - ("service_call", "issue_id"), - [ - *zip( - DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, - ["deprecated_set_program_and_option_actions"] - * ( - len(DEPRECATED_SERVICE_KV_CALL_PARAMS) - + len(SERVICE_PROGRAM_CALL_PARAMS) - ), - strict=True, - ), - *zip( - SERVICE_COMMAND_CALL_PARAMS, - ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), - strict=True, - ), - ], -) -async def test_programs_and_options_actions_deprecation( - service_call: dict[str, Any], - issue_id: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, -) -> None: - """Test deprecated service keys.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) - - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - -@pytest.mark.parametrize( - ("service_call", "called_method"), - zip( - SERVICES_SET_PROGRAM_AND_OPTIONS, - [ - "set_selected_program", - "start_program", - "set_active_program_options", - "set_selected_program_options", - ], - strict=True, - ), -) -async def test_set_program_and_options( - service_call: dict[str, Any], - called_method: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, - snapshot: SnapshotAssertion, -) -> None: - """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - method_mock: MagicMock = getattr(client, called_method) - assert method_mock.call_count == 1 - assert method_mock.call_args == snapshot - - -@pytest.mark.parametrize( - ("service_call", "error_regex"), - zip( - SERVICES_SET_PROGRAM_AND_OPTIONS, - [ - r"Error.*selecting.*program.*", - r"Error.*starting.*program.*", - r"Error.*setting.*options.*active.*program.*", - r"Error.*setting.*options.*selected.*program.*", - ], - strict=True, - ), -) -async def test_set_program_and_options_exceptions( - service_call: dict[str, Any], - error_regex: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, -) -> None: - """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - with pytest.raises(HomeAssistantError, match=error_regex): - await hass.services.async_call(**service_call) - - +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_required_program_or_at_least_an_option( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -599,7 +255,7 @@ async def test_required_program_or_at_least_an_option( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: "Test that the set_program_and_options does raise an exception if no program nor options are set." @@ -609,7 +265,7 @@ async def test_required_program_or_at_least_an_option( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) with pytest.raises( @@ -626,119 +282,13 @@ async def test_required_program_or_at_least_an_option( ) -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_services_exception_device_id( - service_call: dict[str, Any], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a HomeAssistantError when there is an API error.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(HomeAssistantError): - await hass.services.async_call(**service_call) - - -async def test_services_appliance_not_found( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a ServiceValidationError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - service_call = SERVICE_KV_CALL_PARAMS[0] - - service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" - - with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): - await hass.services.async_call(**service_call) - - unrelated_config_entry = MockConfigEntry( - domain="TEST", - ) - unrelated_config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=unrelated_config_entry.entry_id, - identifiers={("RANDOM", "ABCD")}, - ) - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): - await hass.services.async_call(**service_call) - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("RANDOM", "ABCD")}, - ) - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): - await hass.services.async_call(**service_call) - - -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_services_exception( - service_call: dict[str, Any], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a ValueError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - - service_name = service_call["service"] - with pytest.raises( - HomeAssistantError, - match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], - ): - await hass.services.async_call(**service_call) - - +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_v1_1: MockConfigEntry, - appliance_ha_id: str, + appliance: HomeAppliance, platforms: list[Platform], ) -> None: """Test entity migration.""" @@ -747,7 +297,7 @@ async def test_entity_migration( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_v1_1.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) test_entities = [ @@ -787,7 +337,7 @@ async def test_entity_migration( entity_registry.async_get_or_create( domain, DOMAIN, - f"{appliance_ha_id}-{old_unique_id_suffix}", + f"{appliance.ha_id}-{old_unique_id_suffix}", device_id=device_entry.id, config_entry=config_entry_v1_1, ) @@ -798,7 +348,7 @@ async def test_entity_migration( for domain, _, expected_unique_id_suffix in test_entities: assert entity_registry.async_get_entity_id( - domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}" + domain, DOMAIN, f"{appliance.ha_id}-{expected_unique_id_suffix}" ) assert config_entry_v1_1.minor_version == 2 diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 6021c99bb5e..50a1a1e374a 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError @@ -21,9 +22,15 @@ from homeassistant.components.home_connect.const import ( BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -58,9 +65,9 @@ async def test_light( assert config_entry.state == ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -74,7 +81,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -82,7 +89,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -90,7 +97,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -99,7 +106,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -107,14 +114,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -132,14 +139,14 @@ async def test_connected_devices( get_available_programs_mock = client.get_available_programs async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_settings_original_mock.side_effect(ha_id) async def get_available_programs_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -155,14 +162,14 @@ async def test_connected_devices( client.get_settings = get_settings_original_mock client.get_available_programs = get_available_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -170,20 +177,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) -async def test_light_availabilty( +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) +async def test_light_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if light entities availability are based on the appliance connection state.""" entity_ids = [ @@ -201,7 +208,7 @@ async def test_light_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -215,7 +222,7 @@ async def test_light_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -236,7 +243,7 @@ async def test_light_availabilty( "service", "exprected_attributes", "state", - "appliance_ha_id", + "appliance", ), [ ( @@ -256,7 +263,7 @@ async def test_light_availabilty( SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 199}, + {ATTR_BRIGHTNESS: 199}, STATE_ON, "Hood", ), @@ -277,7 +284,7 @@ async def test_light_availabilty( SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 199}, + {ATTR_BRIGHTNESS: 199}, STATE_ON, "Hood", ), @@ -310,7 +317,7 @@ async def test_light_availabilty( }, SERVICE_TURN_ON, { - "rgb_color": (255, 255, 0), + ATTR_RGB_COLOR: (255, 255, 0), }, STATE_ON, "Hood", @@ -324,8 +331,8 @@ async def test_light_availabilty( }, SERVICE_TURN_ON, { - "hs_color": (255.484, 15.196), - "brightness": 199, + ATTR_HS_COLOR: (255.484, 15.196), + ATTR_BRIGHTNESS: 199, }, STATE_ON, "Hood", @@ -341,7 +348,7 @@ async def test_light_availabilty( "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_light_functionality( entity_id: str, @@ -349,7 +356,7 @@ async def test_light_functionality( service: str, exprected_attributes: dict[str, Any], state: str, - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -362,7 +369,7 @@ async def test_light_functionality( assert config_entry.state == ConfigEntryState.LOADED service_data = exprected_attributes.copy() - service_data["entity_id"] = entity_id + service_data[ATTR_ENTITY_ID] = entity_id await hass.services.async_call( LIGHT_DOMAIN, service, @@ -371,7 +378,7 @@ async def test_light_functionality( await hass.async_block_till_done() client.set_setting.assert_has_calls( [ - call(appliance_ha_id, setting_key=setting_key, value=value) + call(appliance.ha_id, setting_key=setting_key, value=value) for setting_key, value in set_settings_args.items() ] ) @@ -386,7 +393,7 @@ async def test_light_functionality( ( "entity_id", "events", - "appliance_ha_id", + "appliance", ), [ ( @@ -397,12 +404,12 @@ async def test_light_functionality( "Hood", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_light_color_different_than_custom( entity_id: str, events: dict[EventKey, Any], - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -417,21 +424,21 @@ async def test_light_color_different_than_custom( LIGHT_DOMAIN, SERVICE_TURN_ON, { - "rgb_color": (255, 255, 0), - "entity_id": entity_id, + ATTR_RGB_COLOR: (255, 255, 0), + ATTR_ENTITY_ID: entity_id, }, ) await hass.async_block_till_done() entity_state = hass.states.get(entity_id) assert entity_state is not None assert entity_state.state == STATE_ON - assert entity_state.attributes["rgb_color"] is not None - assert entity_state.attributes["hs_color"] is not None + assert entity_state.attributes[ATTR_RGB_COLOR] is not None + assert entity_state.attributes[ATTR_HS_COLOR] is not None await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -454,8 +461,8 @@ async def test_light_color_different_than_custom( entity_state = hass.states.get(entity_id) assert entity_state is not None assert entity_state.state == STATE_ON - assert entity_state.attributes["rgb_color"] is None - assert entity_state.attributes["hs_color"] is None + assert entity_state.attributes[ATTR_RGB_COLOR] is None + assert entity_state.attributes[ATTR_HS_COLOR] is None @pytest.mark.parametrize( @@ -485,7 +492,7 @@ async def test_light_color_different_than_custom( SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70, }, SERVICE_TURN_ON, - {"brightness": 200}, + {ATTR_BRIGHTNESS: 200}, [HomeConnectError, HomeConnectError], r"Error.*turn.*on.*", ), @@ -517,7 +524,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, - {"brightness": 200}, + {ATTR_BRIGHTNESS: 200}, [HomeConnectError, None, HomeConnectError], r"Error.*set.*brightness.*", ), @@ -530,7 +537,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, - {"rgb_color": (255, 255, 0)}, + {ATTR_RGB_COLOR: (255, 255, 0)}, [HomeConnectError, None, HomeConnectError], r"Error.*select.*custom color.*", ), @@ -543,7 +550,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, - {"rgb_color": (255, 255, 0)}, + {ATTR_RGB_COLOR: (255, 255, 0)}, [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", ), @@ -556,8 +563,8 @@ async def test_light_color_different_than_custom( }, SERVICE_TURN_ON, { - "hs_color": (255.484, 15.196), - "brightness": 199, + ATTR_HS_COLOR: (255.484, 15.196), + ATTR_BRIGHTNESS: 199, }, [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", @@ -600,7 +607,7 @@ async def test_light_exception_handling( with pytest.raises(HomeConnectError): await client_with_exception.set_setting() - service_data["entity_id"] = entity_id + service_data[ATTR_ENTITY_ID] = entity_id with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( LIGHT_DOMAIN, service, service_data, blocking=True diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index bb87cf9f3dc..1de384303ce 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -69,8 +70,9 @@ async def test_number( assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -95,7 +97,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -103,7 +105,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -111,7 +113,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -120,7 +122,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -128,14 +130,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -152,7 +154,7 @@ async def test_connected_devices( get_settings_original_mock = client.get_settings def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -164,14 +166,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_settings = get_settings_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -179,20 +181,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) -async def test_number_entity_availabilty( +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) +async def test_number_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if number entities availability are based on the appliance connection state.""" entity_ids = [ @@ -215,7 +217,7 @@ async def test_number_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -229,7 +231,7 @@ async def test_number_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -243,7 +245,7 @@ async def test_number_entity_availabilty( assert state.state != STATE_UNAVAILABLE -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -279,7 +281,7 @@ async def test_number_entity_availabilty( ], ) async def test_number_entity_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, type: str, @@ -336,12 +338,12 @@ async def test_number_entity_functionality( ) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, setting_key=setting_key, value=value + appliance.ha_id, setting_key=setting_key, value=value ) assert hass.states.is_state(entity_id, str(float(value))) -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize("retry_after", [0, None]) @pytest.mark.parametrize( ( @@ -368,7 +370,7 @@ async def test_number_entity_functionality( @patch("homeassistant.components.home_connect.entity.API_DEFAULT_RETRY_AFTER", new=0) async def test_fetch_constraints_after_rate_limit_error( retry_after: int | None, - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, type: str, @@ -385,7 +387,7 @@ async def test_fetch_constraints_after_rate_limit_error( """Test that, if a API rate limit error is raised, the constraints are fetched later.""" def get_settings_side_effect(ha_id: str): - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfSettings([]) return ArrayOfSettings( [ @@ -511,7 +513,7 @@ async def test_number_entity_error( ], ) @pytest.mark.parametrize( - ("appliance_ha_id", "entity_id", "option_key", "min", "max", "step_size", "unit"), + ("appliance", "entity_id", "option_key", "min", "max", "step_size", "unit"), [ ( "Oven", @@ -523,12 +525,12 @@ async def test_number_entity_error( "°C", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_options_functionality( entity_id: str, option_key: OptionKey, - appliance_ha_id: str, + appliance: HomeAppliance, min: int, max: int, step_size: int, @@ -615,7 +617,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": 80, diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index f20be33081c..f6009640f72 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -72,8 +73,9 @@ async def test_select( assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -98,7 +100,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -106,7 +108,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -114,7 +116,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -123,7 +125,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -131,13 +133,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -156,13 +159,13 @@ async def test_connected_devices( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -170,19 +173,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries -async def test_select_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_select_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if select entities availability are based on the appliance connection state.""" entity_ids = [ @@ -200,7 +204,7 @@ async def test_select_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -214,7 +218,7 @@ async def test_select_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -290,7 +294,7 @@ async def test_filter_programs( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "expected_initial_state", "mock_method", @@ -318,10 +322,10 @@ async def test_filter_programs( EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_select_program_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, expected_initial_state: str, mock_method: str, @@ -347,14 +351,14 @@ async def test_select_program_functionality( ) await hass.async_block_till_done() getattr(client, mock_method).assert_awaited_once_with( - appliance_ha_id, program_key=program_key + appliance.ha_id, program_key=program_key ) assert hass.states.is_state(entity_id, program_to_set) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -433,13 +437,13 @@ async def test_select_exception_handling( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {"entity_id": entity_id, "option": program_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -473,7 +477,7 @@ async def test_select_exception_handling( ], ) async def test_select_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, expected_options: set[str], @@ -497,12 +501,12 @@ async def test_select_functionality( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: value_to_set}, ) await hass.async_block_till_done() client.set_setting.assert_called_once() - assert client.set_setting.call_args.args == (appliance_ha_id,) + assert client.set_setting.call_args.args == (appliance.ha_id,) assert client.set_setting.call_args.kwargs == { "setting_key": setting_key, "value": expected_value_call_arg, @@ -510,7 +514,7 @@ async def test_select_functionality( assert hass.states.is_state(entity_id, value_to_set) -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -537,7 +541,7 @@ async def test_select_functionality( ], ) async def test_fetch_allowed_values( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, test_setting_key: SettingKey, allowed_values: list[str | None], @@ -554,7 +558,7 @@ async def test_fetch_allowed_values( async def get_setting_side_effect( ha_id: str, setting_key: SettingKey ) -> GetSetting: - if ha_id != appliance_ha_id or setting_key != test_setting_key: + if ha_id != appliance.ha_id or setting_key != test_setting_key: return await original_get_setting_side_effect(ha_id, setting_key) return GetSetting( key=test_setting_key, @@ -576,7 +580,7 @@ async def test_fetch_allowed_values( assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -594,7 +598,7 @@ async def test_fetch_allowed_values( ], ) async def test_fetch_allowed_values_after_rate_limit_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, allowed_values: list[str | None], @@ -608,7 +612,7 @@ async def test_fetch_allowed_values_after_rate_limit_error( """Test fetch allowed values.""" def get_settings_side_effect(ha_id: str): - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfSettings([]) return ArrayOfSettings( [ @@ -648,7 +652,7 @@ async def test_fetch_allowed_values_after_rate_limit_error( assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -669,7 +673,7 @@ async def test_fetch_allowed_values_after_rate_limit_error( ], ) async def test_default_values_after_fetch_allowed_values_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, exception: Exception, @@ -683,7 +687,7 @@ async def test_default_values_after_fetch_allowed_values_error( """Test fetch allowed values.""" def get_settings_side_effect(ha_id: str): - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfSettings([]) return ArrayOfSettings( [ @@ -758,12 +762,13 @@ async def test_select_entity_error( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: value_to_set}, blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ( "set_active_program_options_side_effect", @@ -840,7 +845,7 @@ async def test_options_functionality( option_key: OptionKey, allowed_values: list[str | None] | None, expected_options: set[str], - appliance_ha_id: str, + appliance: HomeAppliance, set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, @@ -894,7 +899,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": "LaundryCare.Washer.EnumType.Temperature.UlWarm", diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index a7836223737..e2f3761dcd9 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,6 +1,7 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable +import logging from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -10,6 +11,7 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + HomeAppliance, Status, StatusKey, ) @@ -99,8 +101,9 @@ async def test_sensors( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -114,7 +117,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -122,7 +125,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -130,7 +133,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -139,7 +142,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -147,13 +150,37 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR, + raw_key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR.value, + timestamp=0, + level="", + handling="", + value=BSH_EVENT_PRESENT_STATE_PRESENT, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state("sensor.washer_poor_i_dos_1_fill_level", "present") + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -170,7 +197,7 @@ async def test_connected_devices( get_status_original_mock = client.get_status def get_status_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -182,14 +209,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_status = get_status_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -197,20 +224,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) -async def test_sensor_entity_availabilty( +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) +async def test_sensor_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if sensor entities availability are based on the appliance connection state.""" entity_ids = [ @@ -221,6 +248,28 @@ async def test_sensor_entity_availabilty( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + raw_key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY.value, + timestamp=0, + level="", + handling="", + value=BSH_EVENT_PRESENT_STATE_OFF, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + for entity_id in entity_ids: state = hass.states.get(entity_id) assert state @@ -229,7 +278,7 @@ async def test_sensor_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -243,7 +292,7 @@ async def test_sensor_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -257,7 +306,7 @@ async def test_sensor_entity_availabilty( assert state.state != STATE_UNAVAILABLE -# Appliance_ha_id program sequence with a delayed start. +# Appliance program sequence with a delayed start. PROGRAM_SEQUENCE_EVENTS = ( EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, @@ -292,7 +341,7 @@ ENTITY_ID_STATES = { } -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("states", "event_run"), list( @@ -305,7 +354,7 @@ ENTITY_ID_STATES = { ) async def test_program_sensors( client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, states: tuple, event_run: dict[EventType, dict[EventKey, str | int]], freezer: FrozenDateTimeFactory, @@ -335,7 +384,7 @@ async def test_program_sensors( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -359,7 +408,7 @@ async def test_program_sensors( assert hass.states.is_state(entity_id, state) -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("initial_operation_state", "initial_state", "event_order", "entity_states"), [ @@ -382,7 +431,7 @@ async def test_program_sensor_edge_case( initial_state: str, event_order: tuple[EventType, EventType], entity_states: tuple[str, str], - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -413,7 +462,7 @@ async def test_program_sensor_edge_case( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -452,9 +501,9 @@ ENTITY_ID_EDGE_CASE_STATES = [ ] -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) async def test_remaining_prog_time_edge_cases( - appliance_ha_id: str, + appliance: HomeAppliance, freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, @@ -478,7 +527,7 @@ async def test_remaining_prog_time_edge_cases( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -506,148 +555,153 @@ async def test_remaining_prog_time_edge_cases( ( "entity_id", "event_key", - "event_type", - "event_value_update", - "expected", - "appliance_ha_id", + "value_expected_state", + "appliance", ), [ ( "sensor.dishwasher_door", EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_LOCKED, - "locked", + [ + ( + BSH_DOOR_STATE_LOCKED, + "locked", + ), + ( + BSH_DOOR_STATE_CLOSED, + "closed", + ), + ( + BSH_DOOR_STATE_OPEN, + "open", + ), + ], "Dishwasher", ), - ( - "sensor.dishwasher_door", - EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_CLOSED, - "closed", - "Dishwasher", - ), - ( - "sensor.dishwasher_door", - EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_OPEN, - "open", - "Dishwasher", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", - EventType.EVENT, - "", - "off", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_OFF, - "off", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_PRESENT, - "present", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_CONFIRMED, - "confirmed", - "FridgeFreezer", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventType.EVENT, - "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", - "", - "off", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_OFF, - "off", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_PRESENT, - "present", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_CONFIRMED, - "confirmed", - "CoffeeMaker", - ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensors_states( entity_id: str, event_key: EventKey, - event_type: EventType, - event_value_update: str, - appliance_ha_id: str, - expected: str, + value_expected_state: list[tuple[str, str]], + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, ) -> None: - """Tests for Appliance_ha_id alarm sensors.""" + """Tests for appliance sensors.""" assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await client.add_events( - [ - EventMessage( - appliance_ha_id, - event_type, - ArrayOfEvents( - [ - Event( - key=event_key, - raw_key=str(event_key), - timestamp=0, - level="", - handling="", - value=event_value_update, - ) - ], + for value, expected_state in value_expected_state: + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), ), - ), - ] - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, expected) + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected_state) @pytest.mark.parametrize( ( - "appliance_ha_id", + "entity_id", + "event_key", + "appliance", + ), + [ + ( + "sensor.fridgefreezer_freezer_door_alarm", + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + "FridgeFreezer", + ), + ( + "sensor.coffeemaker_bean_container_empty", + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + "CoffeeMaker", + ), + ], + indirect=["appliance"], +) +async def test_event_sensors_states( + entity_id: str, + event_key: EventKey, + appliance: HomeAppliance, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Tests for appliance event sensors.""" + caplog.set_level(logging.ERROR) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert not hass.states.get(entity_id) + + for value, expected_state in ( + (BSH_EVENT_PRESENT_STATE_OFF, "off"), + (BSH_EVENT_PRESENT_STATE_PRESENT, "present"), + (BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed"), + ): + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected_state) + + # Verify that the integration doesn't attempt to add the event sensors more than once + # If that happens, the EntityPlatform logs an error with the entity's unique ID. + assert "exists" not in caplog.text + assert entity_id not in caplog.text + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.unique_id not in caplog.text + + +@pytest.mark.parametrize( + ( + "appliance", "entity_id", "status_key", "unit_get_status", @@ -672,10 +726,10 @@ async def test_sensors_states( 1, ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensor_unit_fetching( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, status_key: StatusKey, unit_get_status: str | None, @@ -690,7 +744,7 @@ async def test_sensor_unit_fetching( """Test that the sensor entities are capable of fetching units.""" async def get_status_mock(ha_id: str) -> ArrayOfStatus: - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfStatus([]) return ArrayOfStatus( [ @@ -729,7 +783,7 @@ async def test_sensor_unit_fetching( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "status_key", ), @@ -740,10 +794,10 @@ async def test_sensor_unit_fetching( StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensor_unit_fetching_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, status_key: StatusKey, hass: HomeAssistant, @@ -755,7 +809,7 @@ async def test_sensor_unit_fetching_error( """Test that the sensor entities are capable of fetching units.""" async def get_status_mock(ha_id: str) -> ArrayOfStatus: - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfStatus([]) return ArrayOfStatus( [ @@ -779,7 +833,7 @@ async def test_sensor_unit_fetching_error( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "status_key", "unit", @@ -792,10 +846,10 @@ async def test_sensor_unit_fetching_error( "°C", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensor_unit_fetching_after_rate_limit_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, status_key: StatusKey, unit: str, @@ -808,7 +862,7 @@ async def test_sensor_unit_fetching_after_rate_limit_error( """Test that the sensor entities are capable of fetching units.""" async def get_status_mock(ha_id: str) -> ArrayOfStatus: - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfStatus([]) return ArrayOfStatus( [ diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py new file mode 100644 index 00000000000..2915cbe4f69 --- /dev/null +++ b/tests/components/home_connect/test_services.py @@ -0,0 +1,474 @@ +"""Tests for the Home Connect actions.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock + +from aiohomeconnect.model import HomeAppliance, OptionKey, ProgramKey, SettingKey +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr +import homeassistant.helpers.issue_registry as ir + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + +DEPRECATED_SERVICE_KV_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "set_option_active", + "service_data": { + "device_id": "DEVICE_ID", + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_option_selected", + "service_data": { + "device_id": "DEVICE_ID", + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", + }, + "blocking": True, + }, +] + +SERVICE_KV_CALL_PARAMS = [ + *DEPRECATED_SERVICE_KV_CALL_PARAMS, + { + "domain": DOMAIN, + "service": "change_setting", + "service_data": { + "device_id": "DEVICE_ID", + "key": SettingKey.BSH_COMMON_CHILD_LOCK.value, + "value": True, + }, + "blocking": True, + }, +] + +SERVICE_COMMAND_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "pause_program", + "service_data": { + "device_id": "DEVICE_ID", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "resume_program", + "service_data": { + "device_id": "DEVICE_ID", + }, + "blocking": True, + }, +] + + +SERVICE_PROGRAM_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "select_program", + "service_data": { + "device_id": "DEVICE_ID", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "start_program", + "service_data": { + "device_id": "DEVICE_ID", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", + }, + "blocking": True, + }, +] + +SERVICE_APPLIANCE_METHOD_MAPPING = { + "set_option_active": "set_active_program_option", + "set_option_selected": "set_selected_program_option", + "change_setting": "set_setting", + "pause_program": "put_command", + "resume_program": "put_command", + "select_program": "set_selected_program", + "start_program": "start_program", +} + +SERVICE_VALIDATION_ERROR_MAPPING = { + "set_option_active": r"Error.*setting.*options.*active.*program.*", + "set_option_selected": r"Error.*setting.*options.*selected.*program.*", + "change_setting": r"Error.*assigning.*value.*setting.*", + "pause_program": r"Error.*executing.*command.*", + "resume_program": r"Error.*executing.*command.*", + "select_program": r"Error.*selecting.*program.*", + "start_program": r"Error.*starting.*program.*", +} + + +SERVICES_SET_PROGRAM_AND_OPTIONS = [ + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "program": "dishcare_dishwasher_program_eco_50", + "b_s_h_common_option_start_in_relative": 1800, + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "program": "consumer_products_coffee_maker_program_beverage_coffee", + "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "consumer_products_coffee_maker_option_fill_quantity": 35, + }, + "blocking": True, + }, +] + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_key_value_services( + service_call: dict[str, Any], + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance: HomeAppliance, +) -> None: + """Create and test services.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_name = service_call["service"] + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + assert ( + getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 + ) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + ("service_call", "issue_id"), + [ + *zip( + DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + ["deprecated_set_program_and_option_actions"] + * ( + len(DEPRECATED_SERVICE_KV_CALL_PARAMS) + + len(SERVICE_PROGRAM_CALL_PARAMS) + ), + strict=True, + ), + *zip( + SERVICE_COMMAND_CALL_PARAMS, + ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), + strict=True, + ), + ], +) +async def test_programs_and_options_actions_deprecation( + service_call: dict[str, Any], + issue_id: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance: HomeAppliance, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test deprecated service keys.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + ("service_call", "called_method"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + "set_selected_program", + "start_program", + "set_active_program_options", + "set_selected_program_options", + ], + strict=True, + ), +) +async def test_set_program_and_options( + service_call: dict[str, Any], + called_method: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance: HomeAppliance, + snapshot: SnapshotAssertion, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + method_mock: MagicMock = getattr(client, called_method) + assert method_mock.call_count == 1 + assert method_mock.call_args == snapshot + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + ("service_call", "error_regex"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + r"Error.*selecting.*program.*", + r"Error.*starting.*program.*", + r"Error.*setting.*options.*active.*program.*", + r"Error.*setting.*options.*selected.*program.*", + ], + strict=True, + ), +) +async def test_set_program_and_options_exceptions( + service_call: dict[str, Any], + error_regex: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance: HomeAppliance, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(HomeAssistantError, match=error_regex): + await hass.services.async_call(**service_call) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception_device_id( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance: HomeAppliance, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a HomeAssistantError when there is an API error.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(HomeAssistantError): + await hass.services.async_call(**service_call) + + +async def test_services_appliance_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ServiceValidationError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + service_call = SERVICE_KV_CALL_PARAMS[0] + + service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" + + with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): + await hass.services.async_call(**service_call) + + unrelated_config_entry = MockConfigEntry( + domain="TEST", + ) + unrelated_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=unrelated_config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): + await hass.services.async_call(**service_call) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): + await hass.services.async_call(**service_call) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance: HomeAppliance, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ValueError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + service_name = service_call["service"] + with pytest.raises( + HomeAssistantError, + match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], + ): + await hass.services.async_call(**service_call) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 1b38809dc05..01f9cad5d2e 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -1,6 +1,7 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable +from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -13,6 +14,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -58,6 +60,7 @@ from homeassistant.helpers import ( from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator @pytest.fixture @@ -79,8 +82,9 @@ async def test_switches( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -105,7 +109,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -113,7 +117,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -121,7 +125,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -130,7 +134,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -138,13 +142,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -162,14 +167,14 @@ async def test_connected_devices( get_available_programs_mock = client.get_available_programs async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_settings_original_mock.side_effect(ha_id) async def get_available_programs_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -185,14 +190,14 @@ async def test_connected_devices( client.get_settings = get_settings_original_mock client.get_available_programs = get_available_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -200,20 +205,21 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) -async def test_switch_entity_availabilty( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) +async def test_switch_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if switch entities availability are based on the appliance connection state.""" entity_ids = [ @@ -233,7 +239,7 @@ async def test_switch_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -247,7 +253,7 @@ async def test_switch_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -268,7 +274,7 @@ async def test_switch_entity_availabilty( "settings_key_arg", "setting_value_arg", "state", - "appliance_ha_id", + "appliance", ), [ ( @@ -288,7 +294,7 @@ async def test_switch_entity_availabilty( "Dishwasher", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_switch_functionality( entity_id: str, @@ -300,7 +306,7 @@ async def test_switch_functionality( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test switch functionality.""" @@ -312,13 +318,14 @@ async def test_switch_functionality( await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, setting_key=settings_key_arg, value=setting_value_arg + appliance.ha_id, setting_key=settings_key_arg, value=setting_value_arg ) assert hass.states.is_state(entity_id, state) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("entity_id", "program_key", "initial_state", "appliance_ha_id"), + ("entity_id", "program_key", "initial_state", "appliance"), [ ( "switch.dryer_program_mix", @@ -333,7 +340,7 @@ async def test_switch_functionality( "Dryer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_program_switch_functionality( entity_id: str, @@ -343,7 +350,7 @@ async def test_program_switch_functionality( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test switch functionality.""" @@ -383,7 +390,7 @@ async def test_program_switch_functionality( await hass.async_block_till_done() assert hass.states.is_state(entity_id, STATE_ON) client.start_program.assert_awaited_once_with( - appliance_ha_id, program_key=program_key + appliance.ha_id, program_key=program_key ) await hass.services.async_call( @@ -391,9 +398,10 @@ async def test_program_switch_functionality( ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, STATE_OFF) - client.stop_program.assert_awaited_once_with(appliance_ha_id) + client.stop_program.assert_awaited_once_with(appliance.ha_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ( "entity_id", @@ -496,7 +504,7 @@ async def test_switch_exception_handling( @pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance_ha_id"), + ("entity_id", "status", "service", "state", "appliance"), [ ( "switch.fridgefreezer_freezer_super_mode", @@ -513,7 +521,7 @@ async def test_switch_exception_handling( "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_ent_desc_switch_functionality( entity_id: str, @@ -524,7 +532,7 @@ async def test_ent_desc_switch_functionality( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test switch functionality - entity description setup.""" @@ -544,7 +552,7 @@ async def test_ent_desc_switch_functionality( "status", "service", "mock_attr", - "appliance_ha_id", + "appliance", "exception_match", ), [ @@ -565,7 +573,7 @@ async def test_ent_desc_switch_functionality( r"Error.*turn.*off.*", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_ent_desc_switch_exception_handling( entity_id: str, @@ -577,7 +585,7 @@ async def test_ent_desc_switch_exception_handling( integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client_with_exception: MagicMock, ) -> None: """Test switch exception handling - entity description setup.""" @@ -613,7 +621,7 @@ async def test_ent_desc_switch_exception_handling( "service", "setting_value_arg", "power_state", - "appliance_ha_id", + "appliance", ), [ ( @@ -649,9 +657,9 @@ async def test_ent_desc_switch_exception_handling( "Dishwasher", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) -async def test_power_swtich( +async def test_power_switch( entity_id: str, allowed_values: list[str | None] | None, service: str, @@ -661,7 +669,7 @@ async def test_power_swtich( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test power switch functionality.""" @@ -686,7 +694,7 @@ async def test_power_swtich( await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, + appliance.ha_id, setting_key=SettingKey.BSH_COMMON_POWER_STATE, value=setting_value_arg, ) @@ -798,18 +806,24 @@ async def test_power_switch_service_validation_errors( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_create_issue( +@pytest.mark.parametrize( + "service", + [SERVICE_TURN_ON, SERVICE_TURN_OFF], +) +async def test_create_program_switch_deprecation_issue( hass: HomeAssistant, - appliance_ha_id: str, + appliance: HomeAppliance, + service: str, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: - """Test we create an issue when an automation or script is using a deprecated entity.""" + """Test that we create an issue when an automation or script is using a program switch entity or the entity is used by the user.""" entity_id = "switch.washer_program_mix" - issue_id = f"deprecated_program_switch_{entity_id}" + automation_script_issue_id = f"deprecated_program_switch_{entity_id}" + action_handler_issue_id = f"deprecated_program_switch_{entity_id}" assert await async_setup_component( hass, @@ -848,17 +862,118 @@ async def test_create_issue( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED + await hass.services.async_call( + SWITCH_DOMAIN, + service, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + assert automations_with_entity(hass, entity_id)[0] == "automation.test" assert scripts_with_entity(hass, entity_id)[0] == "script.test" - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 2 + assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert not issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "service", + [SERVICE_TURN_ON, SERVICE_TURN_OFF], +) +async def test_program_switch_deprecation_issue_fix( + hass: HomeAssistant, + appliance: HomeAppliance, + service: str, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test we can fix the issues created when a program switch entity is in an automation or in a script or when is used.""" + entity_id = "switch.washer_program_mix" + automation_script_issue_id = f"deprecated_program_switch_{entity_id}" + action_handler_issue_id = f"deprecated_program_switch_{entity_id}" + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "action": "switch.turn_on", + "entity_id": entity_id, + }, + ], + } + } + }, + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 2 + assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + + for issue in issue_registry.issues.copy().values(): + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) assert len(issue_registry.issues) == 0 @@ -882,7 +997,7 @@ async def test_create_issue( ], ) @pytest.mark.parametrize( - ("entity_id", "option_key", "appliance_ha_id"), + ("entity_id", "option_key", "appliance"), [ ( "switch.dishwasher_half_load", @@ -890,12 +1005,12 @@ async def test_create_issue( "Dishwasher", ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_options_functionality( entity_id: str, option_key: OptionKey, - appliance_ha_id: str, + appliance: HomeAppliance, set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, @@ -933,7 +1048,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": False, @@ -946,7 +1061,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": True, diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index affb5ecfedf..8c23a09053a 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from datetime import time +from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -10,20 +11,32 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest +from homeassistant.components.automation import ( + DOMAIN as AUTOMATION_DOMAIN, + automations_with_entity, +) from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN, scripts_with_entity from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator @pytest.fixture @@ -44,9 +57,10 @@ async def test_time( assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -60,7 +74,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -68,7 +82,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -76,7 +90,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -85,7 +99,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -93,14 +107,15 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -117,7 +132,7 @@ async def test_connected_devices( get_settings_original_mock = client.get_settings async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -129,14 +144,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_settings = get_settings_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -144,20 +159,21 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) -async def test_time_entity_availabilty( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_time_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if time entities availability are based on the appliance connection state.""" entity_ids = [ @@ -175,7 +191,7 @@ async def test_time_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -189,7 +205,7 @@ async def test_time_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -203,7 +219,8 @@ async def test_time_entity_availabilty( assert state.state != STATE_UNAVAILABLE -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key"), [ @@ -214,7 +231,7 @@ async def test_time_entity_availabilty( ], ) async def test_time_entity_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, hass: HomeAssistant, @@ -242,11 +259,12 @@ async def test_time_entity_functionality( ) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, setting_key=setting_key, value=value + appliance.ha_id, setting_key=setting_key, value=value ) assert hass.states.is_state(entity_id, str(time(second=value))) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ @@ -298,3 +316,170 @@ async def test_time_entity_error( blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_create_alarm_clock_deprecation_issue( + hass: HomeAssistant, + appliance: HomeAppliance, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that we create an issue when an automation or script is using a alarm clock time entity or the entity is used by the user.""" + entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" + automation_script_issue_id = ( + f"deprecated_time_alarm_clock_in_automations_scripts_{entity_id}" + ) + action_handler_issue_id = f"deprecated_time_alarm_clock_{entity_id}" + + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + SCRIPT_DOMAIN, + { + SCRIPT_DOMAIN: { + "test": { + "sequence": [ + { + "action": "switch.turn_on", + "entity_id": entity_id, + }, + ], + } + } + }, + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TIME: time(minute=1), + }, + blocking=True, + ) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 2 + assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_alarm_clock_deprecation_issue_fix( + hass: HomeAssistant, + appliance: HomeAppliance, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test we can fix the issues created when a alarm clock time entity is in an automation or in a script or when is used.""" + entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" + automation_script_issue_id = ( + f"deprecated_time_alarm_clock_in_automations_scripts_{entity_id}" + ) + action_handler_issue_id = f"deprecated_time_alarm_clock_{entity_id}" + + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + SCRIPT_DOMAIN, + { + SCRIPT_DOMAIN: { + "test": { + "sequence": [ + { + "action": "switch.turn_on", + "entity_id": entity_id, + }, + ], + } + } + }, + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TIME: time(minute=1), + }, + blocking=True, + ) + + assert len(issue_registry.issues) == 2 + assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + + for issue in issue_registry.issues.copy().values(): + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 32c5a381233..2d5067bea3e 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -381,6 +381,32 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> assert result["step_id"] == "confirm_zigbee" +async def test_config_flow_auto_confirm_if_running(hass: HomeAssistant) -> None: + """Test the config flow skips the confirmation step the hardware is already used.""" + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_firmware_info", + return_value=FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0", + owners=[Mock(is_running=AsyncMock(return_value=True))], + source="guess", + ), + ): + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + # There are no steps, the config entry is automatically created + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert config_entry.data == { + "firmware": "ezsp", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } + + async def test_config_flow_thread(hass: HomeAssistant) -> None: """Test the config flow.""" result = await hass.config_entries.flow.async_init( @@ -558,6 +584,7 @@ async def test_config_flow_zigbee_not_hassio(hass: HomeAssistant) -> None: assert zha_flow["step_id"] == "confirm" +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: """Test the options flow, migrating Zigbee to Thread.""" config_entry = MockConfigEntry( @@ -649,6 +676,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert config_entry.data["firmware"] == "spinel" +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: """Test the options flow, migrating Thread to Zigbee.""" config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index fb38704ae61..38c2696a62a 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -45,6 +45,7 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): STEP_PICK_FIRMWARE_THREAD, ], ) +@pytest.mark.usefixtures("addon_store_info") async def test_config_flow_cannot_probe_firmware( next_step: str, hass: HomeAssistant ) -> None: @@ -660,6 +661,7 @@ async def test_options_flow_zigbee_to_thread_zha_configured( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_thread_to_zigbee_otbr_configured( hass: HomeAssistant, ) -> None: diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py index 0c351141e12..23d1e546791 100644 --- a/tests/components/homeassistant_hardware/test_update.py +++ b/tests/components/homeassistant_hardware/test_update.py @@ -43,6 +43,7 @@ from homeassistant.core import ( ) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -61,7 +62,7 @@ from tests.common import ( TEST_DOMAIN = "test" TEST_DEVICE = "/dev/serial/by-id/some-unique-serial-device-12345" TEST_FIRMWARE_RELEASES_URL = "https://example.org/firmware" -TEST_UPDATE_ENTITY_ID = "update.test_firmware" +TEST_UPDATE_ENTITY_ID = "update.mock_name_firmware" TEST_MANIFEST = FirmwareManifest( url=URL("https://example.org/firmware"), html_url=URL("https://example.org/release_notes"), @@ -205,6 +206,12 @@ class MockFirmwareUpdateEntity(BaseFirmwareUpdateEntity): """Initialize the mock SkyConnect firmware update entity.""" super().__init__(device, config_entry, update_coordinator, entity_description) self._attr_unique_id = self.entity_description.key + self._attr_device_info = DeviceInfo( + identifiers={(TEST_DOMAIN, "yellow")}, + name="Mock Name", + model="Mock Model", + manufacturer="Mock Manufacturer", + ) # Use the cached firmware info if it exists if self._config_entry.data["firmware"] is not None: diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index c5bfa4bd609..89ec292d879 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -47,3 +47,13 @@ def mock_zha_get_last_network_settings() -> Generator[None]: AsyncMock(return_value=None), ): yield + + +@pytest.fixture(autouse=True) +def mock_usb_path_exists() -> Generator[None]: + """Mock os.path.exists to allow the ZBT-1 integration to load.""" + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists", + return_value=True, + ): + yield diff --git a/tests/components/homeassistant_sky_connect/test_hardware.py b/tests/components/homeassistant_sky_connect/test_hardware.py index f39e648b0f2..e59a1e7df06 100644 --- a/tests/components/homeassistant_sky_connect/test_hardware.py +++ b/tests/components/homeassistant_sky_connect/test_hardware.py @@ -28,6 +28,10 @@ CONFIG_ENTRY_DATA_2 = { "firmware": "ezsp", } +CONFIG_ENTRY_DATA_BAD = { + "device": "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_a87b7d75b18beb119fe564a0f320645d-if00-port0", +} + async def test_hardware_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info @@ -59,9 +63,20 @@ async def test_hardware_info( minor_version=2, ) config_entry_2.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry_2.entry_id) + config_entry_bad = MockConfigEntry( + data=CONFIG_ENTRY_DATA_BAD, + domain=DOMAIN, + options={}, + title="Home Assistant Connect ZBT-1", + unique_id="unique_3", + version=1, + minor_version=2, + ) + config_entry_bad.add_to_hass(hass) + assert not await hass.config_entries.async_setup(config_entry_bad.entry_id) + client = await hass_ws_client(hass) await client.send_json({"id": 1, "type": "hardware/info"}) @@ -97,5 +112,6 @@ async def test_hardware_info( "name": "Home Assistant Connect ZBT-1", "url": "https://skyconnect.home-assistant.io/documentation/", }, + # Bad entry is skipped ] } diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index c467a9e0d60..f027a6d2fb8 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -1,15 +1,36 @@ """Test the Home Assistant SkyConnect integration.""" +from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) -from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.components.homeassistant_sky_connect.const import ( + DESCRIPTION, + DOMAIN, + MANUFACTURER, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, +) +from homeassistant.components.usb import USBDevice +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.usb import ( + async_request_scan, + force_usb_polling_watcher, # noqa: F401 + patch_scanned_serial_ports, +) async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: @@ -44,7 +65,7 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.version == 1 - assert config_entry.minor_version == 3 + assert config_entry.minor_version == 4 assert config_entry.data == { "description": "SkyConnect v1.0", "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", @@ -58,3 +79,218 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: } await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: + """Test setup failing when the USB port is missing.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "description": "SkyConnect v1.0", + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + # Set up the config entry + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_exists.return_value = True + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + # Now it's ready + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.usefixtures("force_usb_polling_watcher") +async def test_usb_device_reactivity(hass: HomeAssistant) -> None: + """Test setting up USB monitoring.""" + assert await async_setup_component(hass, "usb", {"usb": {}}) + + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "description": "SkyConnect v1.0", + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + # Now we make it available but do not wait + mock_exists.return_value = True + + with patch_scanned_serial_ports( + return_value=[ + USBDevice( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + vid="10C4", + pid="EA60", + serial_number="3c0ed67c628beb11b1cd64a0f320645d", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", + ) + ], + ): + await async_request_scan(hass) + + # It loads immediately + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Wait for a bit for the USB scan debouncer to cool off + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=5)) + + # Unplug the stick + mock_exists.return_value = False + + with patch_scanned_serial_ports(return_value=[]): + await async_request_scan(hass) + + # The integration has reloaded and is now in a failed state + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_bad_config_entry_fixing(hass: HomeAssistant) -> None: + """Test fixing/deleting config entries with bad data.""" + + # Newly-added ZBT-1 + new_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-9e2adbd75b8beb119fe564a0f320645d", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "9e2adbd75b8beb119fe564a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0 (build 123)", + }, + version=1, + minor_version=3, + ) + + new_entry.add_to_hass(hass) + + # Old config entry, without firmware info + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-3c0ed67c628beb11b1cd64a0f320645d", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_3c0ed67c628beb11b1cd64a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "description": "SkyConnect v1.0", + }, + version=1, + minor_version=1, + ) + + old_entry.add_to_hass(hass) + + # Bad config entry, missing most keys + bad_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-9f6c4bba657cc9a4f0cea48bc5948562", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9f6c4bba657cc9a4f0cea48bc5948562-if00-port0", + }, + version=1, + minor_version=2, + ) + + bad_entry.add_to_hass(hass) + + # Bad config entry, missing most keys, but fixable since the device is present + fixable_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-4f5f3b26d59f8714a78b599690741999", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_4f5f3b26d59f8714a78b599690741999-if00-port0", + }, + version=1, + minor_version=2, + ) + + fixable_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_sky_connect.scan_serial_ports", + return_value=[ + USBDevice( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_4f5f3b26d59f8714a78b599690741999-if00-port0", + vid="10C4", + pid="EA60", + serial_number="4f5f3b26d59f8714a78b599690741999", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", + ) + ], + ): + await async_setup_component(hass, "homeassistant_sky_connect", {}) + + assert hass.config_entries.async_get_entry(new_entry.entry_id) is not None + assert hass.config_entries.async_get_entry(old_entry.entry_id) is not None + assert hass.config_entries.async_get_entry(fixable_entry.entry_id) is not None + + updated_entry = hass.config_entries.async_get_entry(fixable_entry.entry_id) + assert updated_entry is not None + assert updated_entry.data[VID] == "10C4" + assert updated_entry.data[PID] == "EA60" + assert updated_entry.data[SERIAL_NUMBER] == "4f5f3b26d59f8714a78b599690741999" + assert updated_entry.data[MANUFACTURER] == "Nabu Casa" + assert updated_entry.data[PRODUCT] == "SkyConnect v1.0" + assert updated_entry.data[DESCRIPTION] == "SkyConnect v1.0" + + untouched_bad_entry = hass.config_entries.async_get_entry(bad_entry.entry_id) + assert untouched_bad_entry.minor_version == 3 diff --git a/tests/components/homeassistant_sky_connect/test_update.py b/tests/components/homeassistant_sky_connect/test_update.py index 9fb7528987e..b6c7291e0af 100644 --- a/tests/components/homeassistant_sky_connect/test_update.py +++ b/tests/components/homeassistant_sky_connect/test_update.py @@ -1,5 +1,7 @@ """Test SkyConnect firmware update entity.""" +import pytest + from homeassistant.components.homeassistant_hardware.helpers import ( async_notify_firmware_info, ) @@ -14,9 +16,7 @@ from .common import USB_DATA_ZBT1 from tests.common import MockConfigEntry -UPDATE_ENTITY_ID = ( - "update.homeassistant_sky_connect_9e2adbd75b8beb119fe564a0f320645d_firmware" -) +UPDATE_ENTITY_ID = "update.home_assistant_connect_zbt_1_9e2adbd7_firmware" async def test_zbt1_update_entity(hass: HomeAssistant) -> None: @@ -59,8 +59,9 @@ async def test_zbt1_update_entity(hass: HomeAssistant) -> None: await hass.async_block_till_done() state_ezsp = hass.states.get(UPDATE_ENTITY_ID) + assert state_ezsp is not None assert state_ezsp.state == "unknown" - assert state_ezsp.attributes["title"] == "EmberZNet" + assert state_ezsp.attributes["title"] == "EmberZNet Zigbee" assert state_ezsp.attributes["installed_version"] == "7.3.1.0" assert state_ezsp.attributes["latest_version"] is None @@ -80,7 +81,52 @@ async def test_zbt1_update_entity(hass: HomeAssistant) -> None: # After the firmware update, the entity has the new version and the correct state state_spinel = hass.states.get(UPDATE_ENTITY_ID) + assert state_spinel is not None assert state_spinel.state == "unknown" assert state_spinel.attributes["title"] == "OpenThread RCP" assert state_spinel.attributes["installed_version"] == "2.4.4.0" assert state_spinel.attributes["latest_version"] is None + + +@pytest.mark.parametrize( + ("firmware", "version", "expected"), + [ + ("ezsp", "7.3.1.0 build 0", "EmberZNet Zigbee 7.3.1.0"), + ("spinel", "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", "OpenThread RCP 2.4.4.0"), + ("bootloader", "2.4.2", "Gecko Bootloader 2.4.2"), + ("cpc", "4.3.2", "Multiprotocol 4.3.2"), + ("router", "1.2.3.4", "Unknown 1.2.3.4"), # Not supported but still shown + ], +) +async def test_zbt1_update_entity_state( + hass: HomeAssistant, firmware: str, version: str, expected: str +) -> None: + """Test the ZBT-1 firmware update entity with different firmware types.""" + await async_setup_component(hass, "homeassistant", {}) + + zbt1_config_entry = MockConfigEntry( + domain="homeassistant_sky_connect", + data={ + "firmware": firmware, + "firmware_version": version, + "device": USB_DATA_ZBT1.device, + "manufacturer": USB_DATA_ZBT1.manufacturer, + "pid": USB_DATA_ZBT1.pid, + "product": USB_DATA_ZBT1.description, + "serial_number": USB_DATA_ZBT1.serial_number, + "vid": USB_DATA_ZBT1.vid, + }, + version=1, + minor_version=3, + ) + zbt1_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(zbt1_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert ( + f"{state.attributes['title']} {state.attributes['installed_version']}" + == expected + ) diff --git a/tests/components/homeassistant_yellow/test_update.py b/tests/components/homeassistant_yellow/test_update.py index 269ff2afc49..66404dc2176 100644 --- a/tests/components/homeassistant_yellow/test_update.py +++ b/tests/components/homeassistant_yellow/test_update.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant.components.homeassistant_hardware.helpers import ( async_notify_firmware_info, ) @@ -15,7 +17,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -UPDATE_ENTITY_ID = "update.homeassistant_yellow_firmware" +UPDATE_ENTITY_ID = "update.home_assistant_yellow_radio_firmware" async def test_yellow_update_entity(hass: HomeAssistant) -> None: @@ -24,6 +26,7 @@ async def test_yellow_update_entity(hass: HomeAssistant) -> None: # Set up the Yellow integration yellow_config_entry = MockConfigEntry( + title="Home Assistant Yellow", domain="homeassistant_yellow", data={ "firmware": "ezsp", @@ -62,8 +65,9 @@ async def test_yellow_update_entity(hass: HomeAssistant) -> None: await hass.async_block_till_done() state_ezsp = hass.states.get(UPDATE_ENTITY_ID) + assert state_ezsp is not None assert state_ezsp.state == "unknown" - assert state_ezsp.attributes["title"] == "EmberZNet" + assert state_ezsp.attributes["title"] == "EmberZNet Zigbee" assert state_ezsp.attributes["installed_version"] == "7.3.1.0" assert state_ezsp.attributes["latest_version"] is None @@ -83,7 +87,58 @@ async def test_yellow_update_entity(hass: HomeAssistant) -> None: # After the firmware update, the entity has the new version and the correct state state_spinel = hass.states.get(UPDATE_ENTITY_ID) + assert state_spinel is not None assert state_spinel.state == "unknown" assert state_spinel.attributes["title"] == "OpenThread RCP" assert state_spinel.attributes["installed_version"] == "2.4.4.0" assert state_spinel.attributes["latest_version"] is None + + +@pytest.mark.parametrize( + ("firmware", "version", "expected"), + [ + ("ezsp", "7.3.1.0 build 0", "EmberZNet Zigbee 7.3.1.0"), + ("spinel", "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", "OpenThread RCP 2.4.4.0"), + ("bootloader", "2.4.2", "Gecko Bootloader 2.4.2"), + ("cpc", "4.3.2", "Multiprotocol 4.3.2"), + ("router", "1.2.3.4", "Unknown 1.2.3.4"), # Not supported but still shown + ], +) +async def test_yellow_update_entity_state( + hass: HomeAssistant, firmware: str, version: str, expected: str +) -> None: + """Test the Yellow firmware update entity with different firmware types.""" + await async_setup_component(hass, "homeassistant", {}) + + # Set up the Yellow integration + yellow_config_entry = MockConfigEntry( + title="Home Assistant Yellow", + domain="homeassistant_yellow", + data={ + "firmware": firmware, + "firmware_version": version, + "device": RADIO_DEVICE, + }, + version=1, + minor_version=3, + ) + yellow_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_yellow.is_hassio", return_value=True + ), + patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ), + ): + assert await hass.config_entries.async_setup(yellow_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert ( + f"{state.attributes['title']} {state.attributes['installed_version']}" + == expected + ) diff --git a/tests/components/homee/fixtures/lock.json b/tests/components/homee/fixtures/lock.json new file mode 100644 index 00000000000..79fd53e0311 --- /dev/null +++ b/tests/components/homee/fixtures/lock.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "name": "Test Lock", + "profile": 2007, + "image": "default", + "favorite": 0, + "order": 31, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1711799526, + "added": 1645036891, + "history": 1, + "cube_type": 1, + "note": "", + "services": 3, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 232, + "state": 1, + "last_changed": 1711897362, + "changed_by": 4, + "changed_by_id": 5, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_only_targettemp.json b/tests/components/homee/fixtures/thermostat_only_targettemp.json new file mode 100644 index 00000000000..4bdbaa0df78 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_only_targettemp.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "name": "Test Thermostat 1", + "profile": 3003, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 12, + "maximum": 28, + "current_value": 20.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_currenttemp.json b/tests/components/homee/fixtures/thermostat_with_currenttemp.json new file mode 100644 index 00000000000..9685034f178 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_currenttemp.json @@ -0,0 +1,77 @@ +{ + "id": 2, + "name": "Test Thermostat 2", + "profile": 3003, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 2, + "instance": 0, + "minimum": 15, + "maximum": 30, + "current_value": 22.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 2, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_heating_mode.json b/tests/components/homee/fixtures/thermostat_with_heating_mode.json new file mode 100644 index 00000000000..fe06e9ef4a5 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_heating_mode.json @@ -0,0 +1,127 @@ +{ + "id": 3, + "name": "Test Thermostat 3", + "profile": 3006, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 14, + "maximum": 25, + "current_value": 24.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 258, + "state": 1, + "last_changed": 1711796635, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 70.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_preset.json b/tests/components/homee/fixtures/thermostat_with_preset.json new file mode 100644 index 00000000000..63491d45be2 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_preset.json @@ -0,0 +1,98 @@ +{ + "id": 4, + "name": "Test Thermostat 4", + "profile": 3033, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 4, + "instance": 0, + "minimum": 10, + "maximum": 32, + "current_value": 12.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.5, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 4, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + }, + { + "id": 3, + "node_id": 4, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 258, + "state": 1, + "last_changed": 1711796635, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_climate.ambr b/tests/components/homee/snapshots/test_climate.ambr new file mode 100644 index 00000000000..b79538ddcf0 --- /dev/null +++ b/tests/components/homee/snapshots/test_climate.ambr @@ -0,0 +1,274 @@ +# serializer version: 1 +# name: test_climate_snapshot[climate.test_thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 28, + 'min_temp': 12, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 28, + 'min_temp': 12, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 15, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-2-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 2', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 15, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 25, + 'min_temp': 14, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 3', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 25, + 'min_temp': 14, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 24.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 10, + 'preset_modes': list([ + 'none', + 'eco', + 'boost', + 'manual', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-4-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 4', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 10, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'eco', + 'boost', + 'manual', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 12.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/homee/snapshots/test_lock.ambr b/tests/components/homee/snapshots/test_lock.ambr new file mode 100644 index 00000000000..d055039cca4 --- /dev/null +++ b/tests/components/homee/snapshots/test_lock.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_lock_snapshot[lock.test_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_snapshot[lock.test_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': 'unknown-5', + 'friendly_name': 'Test Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- diff --git a/tests/components/homee/test_climate.py b/tests/components/homee/test_climate.py new file mode 100644 index 00000000000..bb5ad98c7d2 --- /dev/null +++ b/tests/components/homee/test_climate.py @@ -0,0 +1,270 @@ +"""Test Homee climate entities.""" + +from unittest.mock import MagicMock, patch + +from pyHomee.const import AttributeType +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.homee.const import PRESET_MANUAL +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_mock_climate( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + file: str, +) -> None: + """Setups a climate node for the tests.""" + mock_homee.nodes = [build_mock_node(file)] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("file", "entity_id", "features", "hvac_modes"), + [ + ( + "thermostat_only_targettemp.json", + "climate.test_thermostat_1", + ClimateEntityFeature.TARGET_TEMPERATURE, + [HVACMode.HEAT], + ), + ( + "thermostat_with_currenttemp.json", + "climate.test_thermostat_2", + ClimateEntityFeature.TARGET_TEMPERATURE, + [HVACMode.HEAT], + ), + ( + "thermostat_with_heating_mode.json", + "climate.test_thermostat_3", + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF, + [HVACMode.HEAT, HVACMode.OFF], + ), + ( + "thermostat_with_preset.json", + "climate.test_thermostat_4", + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE, + [HVACMode.HEAT, HVACMode.OFF], + ), + ], +) +async def test_climate_features( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + file: str, + entity_id: str, + features: ClimateEntityFeature, + hvac_modes: list[HVACMode], +) -> None: + """Test available features of climate entities.""" + await setup_mock_climate(hass, mock_config_entry, mock_homee, file) + + attributes = hass.states.get(entity_id).attributes + assert attributes["supported_features"] == features + assert attributes[ATTR_HVAC_MODES] == hvac_modes + + +async def test_climate_preset_modes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, +) -> None: + """Test available preset modes of climate entities.""" + await setup_mock_climate( + hass, mock_config_entry, mock_homee, "thermostat_with_preset.json" + ) + + attributes = hass.states.get("climate.test_thermostat_4").attributes + assert attributes[ATTR_PRESET_MODES] == [ + PRESET_NONE, + PRESET_ECO, + PRESET_BOOST, + PRESET_MANUAL, + ] + + +@pytest.mark.parametrize( + ("attribute_type", "value", "expected"), + [ + (AttributeType.HEATING_MODE, 0.0, HVACAction.OFF), + (AttributeType.CURRENT_VALVE_POSITION, 0.0, HVACAction.IDLE), + (AttributeType.TEMPERATURE, 25.0, HVACAction.IDLE), + (AttributeType.TEMPERATURE, 18.0, HVACAction.HEATING), + ], +) +async def test_hvac_action( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + attribute_type: AttributeType, + value: float, + expected: HVACAction, +) -> None: + """Test hvac action of climate entities.""" + mock_homee.nodes = [build_mock_node("thermostat_with_heating_mode.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + node = mock_homee.nodes[0] + # set target temperature to 24.0 + node.attributes[0].current_value = 24.0 + attribute = node.get_attribute_by_type(attribute_type) + attribute.current_value = value + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("climate.test_thermostat_3").attributes + assert attributes[ATTR_HVAC_ACTION] == expected + + +@pytest.mark.parametrize( + ("preset_mode_int", "expected"), + [ + (0, PRESET_NONE), + (1, PRESET_NONE), + (2, PRESET_ECO), + (3, PRESET_BOOST), + (4, PRESET_MANUAL), + ], +) +async def test_current_preset_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + preset_mode_int: int, + expected: str, +) -> None: + """Test current preset mode of climate entities.""" + mock_homee.nodes = [build_mock_node("thermostat_with_preset.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + node = mock_homee.nodes[0] + node.attributes[2].current_value = preset_mode_int + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("climate.test_thermostat_4").attributes + assert attributes[ATTR_PRESET_MODE] == expected + + +@pytest.mark.parametrize( + ("service", "service_data", "expected"), + [ + ( + SERVICE_TURN_ON, + {}, + (4, 3, 1), + ), + ( + SERVICE_TURN_OFF, + {}, + (4, 3, 0), + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + (4, 3, 1), + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + (4, 3, 0), + ), + ( + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 20}, + (4, 1, 20), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_NONE}, + (4, 3, 1), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_ECO}, + (4, 3, 2), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_BOOST}, + (4, 3, 3), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_MANUAL}, + (4, 3, 4), + ), + ], +) +async def test_climate_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + service: str, + service_data: dict, + expected: tuple[int, int, int], +) -> None: + """Test available services of climate entities.""" + await setup_mock_climate( + hass, mock_config_entry, mock_homee, "thermostat_with_preset.json" + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: "climate.test_thermostat_4", **service_data}, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(*expected) + + +async def test_climate_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test snapshot of climates.""" + mock_homee.nodes = [ + build_mock_node("thermostat_only_targettemp.json"), + build_mock_node("thermostat_with_currenttemp.json"), + build_mock_node("thermostat_with_heating_mode.json"), + build_mock_node("thermostat_with_preset.json"), + ] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_lock.py b/tests/components/homee/test_lock.py new file mode 100644 index 00000000000..3e6ff3f8ec6 --- /dev/null +++ b/tests/components/homee/test_lock.py @@ -0,0 +1,125 @@ +"""Test Homee locks.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, + LockState, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_lock( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homee: MagicMock +) -> None: + """Setups the integration lock tests.""" + mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("service", "target_value"), + [ + (SERVICE_LOCK, 1), + (SERVICE_UNLOCK, 0), + ], +) +async def test_lock_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + service: str, + target_value: int, +) -> None: + """Test lock services.""" + await setup_lock(hass, mock_config_entry, mock_homee) + + await hass.services.async_call( + LOCK_DOMAIN, + service, + {ATTR_ENTITY_ID: "lock.test_lock"}, + ) + mock_homee.set_value.assert_called_once_with(1, 1, target_value) + + +@pytest.mark.parametrize( + ("target_value", "current_value", "expected"), + [ + (1.0, 1.0, LockState.LOCKED), + (0.0, 0.0, LockState.UNLOCKED), + (1.0, 0.0, LockState.LOCKING), + (0.0, 1.0, LockState.UNLOCKING), + ], +) +async def test_lock_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + target_value: float, + current_value: float, + expected: LockState, +) -> None: + """Test lock state.""" + mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + attribute = mock_homee.nodes[0].attributes[0] + attribute.target_value = target_value + attribute.current_value = current_value + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("lock.test_lock").state == expected + + +@pytest.mark.parametrize( + ("attr_changed_by", "changed_by_id", "expected"), + [ + (1, 0, "itself-0"), + (2, 1, "user-testuser"), + (3, 54, "homeegram-54"), + (6, 0, "ai-0"), + ], +) +async def test_lock_changed_by( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + attr_changed_by: int, + changed_by_id: int, + expected: str, +) -> None: + """Test lock changed by entries.""" + mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + mock_homee.get_user_by_id.return_value = MagicMock(username="testuser") + attribute = mock_homee.nodes[0].attributes[0] + attribute.changed_by = attr_changed_by + attribute.changed_by_id = changed_by_id + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("lock.test_lock").attributes["changed_by"] == expected + + +async def test_lock_snapshot( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the lock snapshots.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.LOCK]): + await setup_lock(hass, mock_config_entry, mock_homee) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homekit_controller/fixtures/ecobee3_lite.json b/tests/components/homekit_controller/fixtures/ecobee3_lite.json new file mode 100644 index 00000000000..0656ed20fdb --- /dev/null +++ b/tests/components/homekit_controller/fixtures/ecobee3_lite.json @@ -0,0 +1,3436 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "ecobee3 lite", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Thermostat", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "4.8.70226", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "34AB8811-AC7F-4340-BAC3-FD6A85F9943B", + "iid": 11, + "perms": ["pr", "hd"], + "format": "string", + "value": "4.1;3fac0fb4", + "maxLen": 64 + }, + { + "type": "00000220-0000-1000-8000-0026BB765291", + "iid": 10, + "perms": ["pr", "hd"], + "format": "data", + "value": "u4qz9YgSXzQ=" + }, + { + "type": "000000A6-0000-1000-8000-0026BB765291", + "iid": 9, + "perms": ["pr", "ev"], + "format": "uint32", + "value": 0, + "description": "Accessory Flags" + } + ] + }, + { + "iid": 30, + "type": "000000A2-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000037-0000-1000-8000-0026BB765291", + "iid": 31, + "perms": ["pr"], + "format": "string", + "value": "1.1.0", + "description": "Version", + "maxLen": 64 + } + ] + }, + { + "iid": 16, + "type": "0000004A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000000F-0000-1000-8000-0026BB765291", + "iid": 17, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Current Heating Cooling State", + "minValue": 0, + "maxValue": 2, + "minStep": 1, + "valid-values": [0, 1, 2] + }, + { + "type": "00000033-0000-1000-8000-0026BB765291", + "iid": 18, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 0, + "description": "Target Heating Cooling State", + "minValue": 0, + "maxValue": 3, + "minStep": 1, + "valid-values": [0, 1, 2, 3] + }, + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 19, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.2, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 40.0, + "minStep": 0.1 + }, + { + "type": "00000035-0000-1000-8000-0026BB765291", + "iid": 20, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 22.2, + "description": "Target Temperature", + "unit": "celsius", + "minValue": 7.2, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "00000036-0000-1000-8000-0026BB765291", + "iid": 21, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 1, + "description": "Temperature Display Units", + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "valid-values": [0, 1] + }, + { + "type": "0000000D-0000-1000-8000-0026BB765291", + "iid": 22, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 25.0, + "description": "Cooling Threshold Temperature", + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "00000012-0000-1000-8000-0026BB765291", + "iid": 23, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 22.2, + "description": "Heating Threshold Temperature", + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "00000010-0000-1000-8000-0026BB765291", + "iid": 24, + "perms": ["pr", "ev"], + "format": "float", + "value": 45.0, + "description": "Current Relative Humidity", + "unit": "percentage", + "minValue": 0, + "maxValue": 100.0, + "minStep": 1.0 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 27, + "perms": ["pr"], + "format": "string", + "value": "Thermostat", + "description": "Name", + "maxLen": 64 + }, + { + "type": "000000BF-0000-1000-8000-0026BB765291", + "iid": 75, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 0, + "description": "Target Fan State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "000000AF-0000-1000-8000-0026BB765291", + "iid": 76, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Current Fan State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "B7DDB9A3-54BB-4572-91D2-F1F5B0510F8C", + "iid": 33, + "perms": ["pr"], + "format": "uint8", + "value": 3, + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "E4489BBC-5227-4569-93E5-B345E3E5508F", + "iid": 34, + "perms": ["pr", "pw"], + "format": "float", + "value": 22.2, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "7D381BAA-20F9-40E5-9BE9-AEB92D4BECEF", + "iid": 35, + "perms": ["pr", "pw"], + "format": "float", + "value": 25.0, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "73AAB542-892A-4439-879A-D2A883724B69", + "iid": 36, + "perms": ["pr", "pw"], + "format": "float", + "value": 17.8, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "5DA985F0-898A-4850-B987-B76C6C78D670", + "iid": 37, + "perms": ["pr", "pw"], + "format": "float", + "value": 25.6, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "05B97374-6DC0-439B-A0FA-CA33F612D425", + "iid": 38, + "perms": ["pr", "pw"], + "format": "float", + "value": 20.0, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "A251F6E7-AC46-4190-9C5D-3D06277BDF9F", + "iid": 39, + "perms": ["pr", "pw"], + "format": "float", + "value": 24.4, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "1B300BC2-CFFC-47FF-89F9-BD6CCF5F2853", + "iid": 40, + "perms": ["pw"], + "format": "uint8", + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "1621F556-1367-443C-AF19-82AF018E99DE", + "iid": 41, + "perms": ["pr", "pw"], + "format": "string", + "value": "2025-04-06T23:30:00-05:00R", + "maxLen": 64 + }, + { + "type": "FA128DE6-9D7D-49A4-B6D8-4E4E234DEE38", + "iid": 48, + "perms": ["pw"], + "format": "bool" + }, + { + "type": "4A6AE4F6-036C-495D-87CC-B3702B437741", + "iid": 49, + "perms": ["pr"], + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 4, + "minStep": 1 + }, + { + "type": "DB7BF261-7042-4194-8BD1-3AA22830AEDD", + "iid": 50, + "perms": ["pr"], + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "41935E3E-B54D-42E9-B8B9-D33C6319F0AF", + "iid": 51, + "perms": ["pr"], + "format": "bool", + "value": false + }, + { + "type": "C35DA3C0-E004-40E3-B153-46655CDD9214", + "iid": 52, + "perms": ["pr", "pw"], + "format": "uint8", + "value": 100, + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "48F62AEC-4171-4B4A-8F0E-1EEB6708B3FB", + "iid": 53, + "perms": ["pr"], + "format": "uint8", + "value": 100, + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "1B1515F2-CC45-409F-991F-C480987F92C3", + "iid": 54, + "perms": ["pr"], + "format": "string", + "value": "The Hive is humming along. You have no pending alerts or reminders.", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4295608971, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Master BR", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Master BR", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 22.4, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Master BR Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Master BR Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 691, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Master BR Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 691, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295608960, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Basement", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Basement", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 20.3, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Basement Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Basement Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 9158, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Basement Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 9158, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295016858, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Living Room", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Living Room", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.0, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Living Room Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Living Room Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Living Room Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295016969, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.6, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4298584118, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1421, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 821, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298649931, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Loft window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Loft window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Loft window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 327, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Loft window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 328, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Loft window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298527970, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Front Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Front Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Front Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1473, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Front Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 873, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Front Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298527962, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Garage Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Garage Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1189, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 888, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360914, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360921, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Deck Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Deck Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 944, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 884, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360712, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1923, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 625, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298568508, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 9060, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 9060, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 62b53df33f2..3bb9eb48106 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -4195,6 +4195,3464 @@ }), ]) # --- +# name: test_snapshots[ecobee3_lite] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4295608960', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement Motion', + }), + 'entity_id': 'binary_sensor.basement_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Basement Occupancy', + }), + 'entity_id': 'binary_sensor.basement_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Basement Identify', + }), + 'entity_id': 'button.basement_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Basement Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.basement_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + '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, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_208', + '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.3', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4298360914', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Basement Window 1', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Basement Window 1 Contact', + }), + 'entity_id': 'binary_sensor.basement_window_1_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement Window 1 Motion', + }), + 'entity_id': 'binary_sensor.basement_window_1_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Basement Window 1 Occupancy', + }), + 'entity_id': 'binary_sensor.basement_window_1_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_window_1_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Basement Window 1 Identify', + }), + 'entity_id': 'button.basement_window_1_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_window_1_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Basement Window 1 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Window 1 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.basement_window_1_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4298360921', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Deck Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Deck Door Contact', + }), + 'entity_id': 'binary_sensor.deck_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Deck Door Motion', + }), + 'entity_id': 'binary_sensor.deck_door_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Deck Door Occupancy', + }), + 'entity_id': 'binary_sensor.deck_door_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.deck_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Deck Door Identify', + }), + 'entity_id': 'button.deck_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.deck_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Deck Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Deck Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.deck_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4298527970', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Front Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Front Door Contact', + }), + 'entity_id': 'binary_sensor.front_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Front Door Motion', + }), + 'entity_id': 'binary_sensor.front_door_motion', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Front Door Occupancy', + }), + 'entity_id': 'binary_sensor.front_door_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.front_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Front Door Identify', + }), + 'entity_id': 'button.front_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Front Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Front Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.front_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4298527962', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Garage Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Garage Door Contact', + }), + 'entity_id': 'binary_sensor.garage_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Garage Door Motion', + }), + 'entity_id': 'binary_sensor.garage_door_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Garage Door Occupancy', + }), + 'entity_id': 'binary_sensor.garage_door_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.garage_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Garage Door Identify', + }), + 'entity_id': 'button.garage_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Garage Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Garage Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.garage_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4295016858', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Living Room', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Living Room Motion', + }), + 'entity_id': 'binary_sensor.living_room_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Living Room Occupancy', + }), + 'entity_id': 'binary_sensor.living_room_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Living Room Identify', + }), + 'entity_id': 'button.living_room_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_room_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Living Room Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.living_room_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Living Room Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.living_room_temperature', + 'state': '21.0', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4298360712', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Living Room Window 1', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Living Room Window 1 Contact', + }), + 'entity_id': 'binary_sensor.living_room_window_1_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Living Room Window 1 Motion', + }), + 'entity_id': 'binary_sensor.living_room_window_1_motion', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Living Room Window 1 Occupancy', + }), + 'entity_id': 'binary_sensor.living_room_window_1_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_window_1_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Living Room Window 1 Identify', + }), + 'entity_id': 'button.living_room_window_1_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_room_window_1_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Living Room Window 1 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Window 1 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.living_room_window_1_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4298649931', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Loft window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Loft window Contact', + }), + 'entity_id': 'binary_sensor.loft_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Loft window Motion', + }), + 'entity_id': 'binary_sensor.loft_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Loft window Occupancy', + }), + 'entity_id': 'binary_sensor.loft_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.loft_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Loft window Identify', + }), + 'entity_id': 'button.loft_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.loft_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Loft window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Loft window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.loft_window_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4295608971', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Master BR', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Master BR Motion', + }), + 'entity_id': 'binary_sensor.master_br_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Master BR Occupancy', + }), + 'entity_id': 'binary_sensor.master_br_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_br_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Master BR Identify', + }), + 'entity_id': 'button.master_br_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.master_br_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Master BR Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Master BR Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.master_br_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.master_br_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Master BR Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.master_br_temperature', + 'state': '22.4', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4298584118', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Master BR Window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Master BR Window Contact', + }), + 'entity_id': 'binary_sensor.master_br_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Master BR Window Motion', + }), + 'entity_id': 'binary_sensor.master_br_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Master BR Window Occupancy', + }), + 'entity_id': 'binary_sensor.master_br_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_br_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Master BR Window Identify', + }), + 'entity_id': 'button.master_br_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.master_br_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Master BR Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Master BR Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.master_br_window_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3 lite', + 'model_id': None, + 'name': 'Thermostat', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '4.8.70226', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.thermostat_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Clear Hold', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + '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': 'Thermostat Clear Hold', + }), + 'entity_id': 'button.thermostat_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.thermostat_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + '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({ + 'device_class': 'identify', + 'friendly_name': 'Thermostat Identify', + }), + 'entity_id': 'button.thermostat_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + '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': 45.0, + 'current_temperature': 21.2, + 'fan_mode': 'on', + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'friendly_name': 'Thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'entity_id': 'climate.thermostat', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.thermostat_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Current Mode', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + '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': 'Thermostat Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.thermostat_current_mode', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.thermostat_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Temperature Display Units', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + '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': 'Thermostat Temperature Display Units', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.thermostat_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + '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': 'Thermostat Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.thermostat_current_humidity', + 'state': '45.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Current Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + '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': 'Thermostat Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.thermostat_current_temperature', + 'state': '21.2', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4295016969', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Upstairs BR', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Upstairs BR Motion', + }), + 'entity_id': 'binary_sensor.upstairs_br_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Upstairs BR Occupancy', + }), + 'entity_id': 'binary_sensor.upstairs_br_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.upstairs_br_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Upstairs BR Identify', + }), + 'entity_id': 'button.upstairs_br_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.upstairs_br_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Upstairs BR Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs BR Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.upstairs_br_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.upstairs_br_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Upstairs BR Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.upstairs_br_temperature', + 'state': '21.6', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + '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:4298568508', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Upstairs BR Window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Upstairs BR Window Contact', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Upstairs BR Window Motion', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Upstairs BR Window Occupancy', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.upstairs_br_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Upstairs BR Window Identify', + }), + 'entity_id': 'button.upstairs_br_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.upstairs_br_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Upstairs BR Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs BR Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.upstairs_br_window_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[ecobee3_no_sensors] list([ dict({ diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 62c73af9977..b119b5f7b80 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -8,6 +8,8 @@ from aiohomekit.model.characteristics import ( CharacteristicsTypes, CurrentFanStateValues, CurrentHeaterCoolerStateValues, + HeatingCoolingCurrentValues, + HeatingCoolingTargetValues, SwingModeValues, TargetHeaterCoolerStateValues, ) @@ -20,6 +22,7 @@ from homeassistant.components.climate import ( SERVICE_SET_HVAC_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + HVACAction, HVACMode, ) from homeassistant.core import HomeAssistant @@ -662,7 +665,7 @@ async def test_hvac_mode_vs_hvac_action( state = await helper.poll_and_get_state() assert state.state == "heat" - assert state.attributes["hvac_action"] == "fan" + assert state.attributes["hvac_action"] == HVACAction.FAN # Simulate that current temperature is below target temp # Heating might be on and hvac_action currently 'heat' @@ -676,7 +679,23 @@ async def test_hvac_mode_vs_hvac_action( state = await helper.poll_and_get_state() assert state.state == "heat" - assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_action"] == HVACAction.HEATING + + # If the fan is active, and the heating is off, the hvac_action should be 'fan' + # and not 'idle' or 'heating' + await helper.async_update( + ServicesTypes.THERMOSTAT, + { + CharacteristicsTypes.FAN_STATE_CURRENT: CurrentFanStateValues.ACTIVE, + CharacteristicsTypes.HEATING_COOLING_CURRENT: HeatingCoolingCurrentValues.IDLE, + CharacteristicsTypes.HEATING_COOLING_TARGET: HeatingCoolingTargetValues.OFF, + CharacteristicsTypes.FAN_STATE_CURRENT: CurrentFanStateValues.ACTIVE, + }, + ) + + state = await helper.poll_and_get_state() + assert state.state == HVACMode.OFF + assert state.attributes["hvac_action"] == HVACAction.FAN async def test_hvac_mode_vs_hvac_action_current_mode_wrong( diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index e31e630807e..8bf2e66a286 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -18,7 +18,6 @@ from homeassistant.auth.models import User from homeassistant.auth.providers import trusted_networks from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import websocket_api -from homeassistant.components.http import KEY_HASS from homeassistant.components.http.auth import ( CONTENT_USER_NAME, DATA_SIGN_SECRET, @@ -28,13 +27,13 @@ from homeassistant.components.http.auth import ( async_sign_path, async_user_not_allowed_do_auth, ) -from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.forwarded import async_setup_forwarded from homeassistant.components.http.request_context import ( current_request, setup_request_context, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.setup import async_setup_component from . import HTTP_HEADER_HA_AUTH diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 59011de0cfd..51d3e4ed992 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -11,7 +11,6 @@ from aiohttp.web_middlewares import middleware import pytest from homeassistant.components import http -from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.components.http.ban import ( IP_BANS_FILE, KEY_BAN_MANAGER, @@ -22,6 +21,7 @@ from homeassistant.components.http.ban import ( from homeassistant.components.http.view import request_handler_factory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.setup import async_setup_component from tests.common import async_get_persistent_notifications diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index c0256abb25d..0581c7bac2a 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -1,6 +1,5 @@ """Test cors for the HTTP component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus from pathlib import Path from unittest.mock import patch @@ -18,9 +17,8 @@ from aiohttp.test_utils import TestClient import pytest from homeassistant.components.http.cors import setup_cors -from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant -from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS +from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS, HomeAssistantView from homeassistant.setup import async_setup_component from . import HTTP_HEADER_HA_AUTH @@ -56,14 +54,12 @@ async def mock_handler(request): @pytest.fixture -def client( - event_loop: AbstractEventLoop, aiohttp_client: ClientSessionGenerator -) -> TestClient: +async def client(aiohttp_client: ClientSessionGenerator) -> TestClient: """Fixture to set up a web.Application.""" app = web.Application() setup_cors(app, [TRUSTED_ORIGIN]) app[KEY_ALLOW_CONFIGURED_CORS](app.router.add_get("/", mock_handler)) - return event_loop.run_until_complete(aiohttp_client(app)) + return await aiohttp_client(app) async def test_cors_requests(client) -> None: diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index c831d40d261..f4a6fcfba93 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -2,9 +2,14 @@ from unittest.mock import Mock -from homeassistant.components.light import ColorMode +from homeassistant.components.light import ( + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, + ColorMode, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.util.json import JsonArrayType from .conftest import setup_platform @@ -42,8 +47,8 @@ async def test_lights( assert light_1.attributes["min_mireds"] == 153 assert light_1.attributes["max_mireds"] == 500 assert light_1.attributes["dynamics"] == "dynamic_palette" - assert light_1.attributes["effect_list"] == ["None", "candle", "fire"] - assert light_1.attributes["effect"] == "None" + assert light_1.attributes["effect_list"] == ["off", "candle", "fire"] + assert light_1.attributes["effect"] == "off" # test light which supports color temperature only light_2 = hass.states.get("light.hue_light_with_color_temperature_only") @@ -57,7 +62,7 @@ async def test_lights( assert light_2.attributes["min_mireds"] == 153 assert light_2.attributes["max_mireds"] == 454 assert light_2.attributes["dynamics"] == "none" - assert light_2.attributes["effect_list"] == ["None", "candle", "sunrise"] + assert light_2.attributes["effect_list"] == ["off", "candle", "sunrise"] # test light which supports color only light_3 = hass.states.get("light.hue_light_with_color_only") @@ -201,7 +206,7 @@ async def test_light_turn_on_service( await hass.services.async_call( "light", "turn_on", - {"entity_id": test_light_id, "effect": "None"}, + {"entity_id": test_light_id, "effect": "off"}, blocking=True, ) assert len(mock_bridge_v2.mock_requests) == 8 @@ -216,14 +221,14 @@ async def test_light_turn_on_service( await hass.async_block_till_done() test_light = hass.states.get(test_light_id) assert test_light is not None - assert test_light.attributes["effect"] == "None" + assert test_light.attributes["effect"] == "off" # test turn on with useless effect # it should send a effect in the request if the device has no effect active await hass.services.async_call( "light", "turn_on", - {"entity_id": test_light_id, "effect": "None"}, + {"entity_id": test_light_id, "effect": "off"}, blocking=True, ) assert len(mock_bridge_v2.mock_requests) == 9 @@ -639,3 +644,38 @@ async def test_grouped_lights( mock_bridge_v2.mock_requests[index]["json"]["identify"]["action"] == "identify" ) + + +async def test_light_turn_on_service_deprecation( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + issue_registry: ir.IssueRegistry, +) -> None: + """Test calling the turn on service on a light.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + test_light_id = "light.hue_light_with_color_temperature_only" + + await setup_platform(hass, mock_bridge_v2, "light") + + event = { + "id": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "type": "light", + "effects": {"status": "candle"}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # test disable effect + # it should send a request with effect set to "no_effect" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: test_light_id, + ATTR_EFFECT: "None", + }, + blocking=True, + ) + assert mock_bridge_v2.mock_requests[0]["json"]["effects"]["effect"] == "no_effect" diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index ee368bf6546..06e11ec1252 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -176,7 +176,7 @@ ], "statistics": { "cuttingBladeUsageTime": 123, - "downTime": 123, + "downTime": 3600, "numberOfChargingCycles": 1380, "numberOfCollisions": 11396, "totalChargingTime": 4334400, @@ -184,7 +184,7 @@ "totalDriveDistance": 1780272, "totalRunningTime": 4564800, "totalSearchingTime": 370800, - "upTime": 456 + "upTime": 7200 }, "stayOutZones": { "dirty": false, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 9d5004c8f6d..d5546b0d2af 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -95,7 +95,7 @@ }), 'statistics': dict({ 'cutting_blade_usage_time': 123, - 'downtime': 123, + 'downtime': 3600, 'number_of_charging_cycles': 1380, 'number_of_collisions': 11396, 'total_charging_time': 4334400, @@ -103,7 +103,7 @@ 'total_drive_distance': 1780272, 'total_running_time': 4564800, 'total_searching_time': 370800, - 'uptime': 456, + 'uptime': 7200, }), 'stay_out_zones': dict({ 'dirty': False, diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 02a64718276..92320de6fdb 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -106,6 +106,64 @@ 'state': '0.034', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_1_downtime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_downtime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Downtime', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'downtime', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_downtime', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_downtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Mower 1 Downtime', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_downtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_mower_1_error-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1208,6 +1266,64 @@ 'state': '103.000', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_1_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_uptime', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Mower 1 Uptime', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_mower_1_work_area-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index 55bf5dda7eb..814846ae1c6 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -79,7 +79,7 @@ async def test_number_workarea_commands( freezer.tick(timedelta(seconds=EXECUTION_TIME_DELAY)) async_fire_time_changed(hass) await hass.async_block_till_done() - mocked_method.assert_called_once_with(TEST_MOWER_ID, 75, 123456) + mocked_method.assert_called_once_with(TEST_MOWER_ID, 123456, cutting_height=75) state = hass.states.get(entity_id) assert state.state is not None assert state.state == "75" diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index 08ed5251344..85d20178e73 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -110,6 +110,18 @@ async def test_work_area_sensor( state = hass.states.get("sensor.test_mower_1_work_area") assert state.state == "my_lawn" + # Test EPOS mower, which returns work_area_id = 0, when no + # work area is active and has no default work_area_id=0 + values[TEST_MOWER_ID].mower.work_area_id = 0 + del values[TEST_MOWER_ID].work_areas[0] + del values[TEST_MOWER_ID].work_area_dict[0] + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_mower_1_work_area") + assert state.state == "no_work_area_active" + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 01ae0bf8efc..e285e1cbf2d 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -22,6 +22,18 @@ SPS_SERVICE_INFO = BluetoothServiceInfo( source="local", ) + +SPS_PASSIVE_SERVICE_INFO = BluetoothServiceInfo( + name="sps", + address="AA:BB:CC:DD:EE:FF", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + source="local", +) + + SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( name="XXXXcorruptXXXX", address="AA:BB:CC:DD:EE:FF", diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 0f3d6497c2b..00b76366b48 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -1,16 +1,63 @@ """Test the INKBIRD config flow.""" +from unittest.mock import patch + +from inkbird_ble import ( + DeviceKey, + SensorDescription, + SensorDeviceInfo, + SensorUpdate, + SensorValue, + Units, +) +from sensor_state_data import SensorDeviceClass + +from homeassistant.components.inkbird import FALLBACK_POLL_INTERVAL from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import SPS_SERVICE_INFO, SPS_WITH_CORRUPT_NAME_SERVICE_INFO +from . import ( + SPS_PASSIVE_SERVICE_INFO, + SPS_SERVICE_INFO, + SPS_WITH_CORRUPT_NAME_SERVICE_INFO, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import inject_bluetooth_service_info +def _make_sensor_update(humidity: float) -> SensorUpdate: + return SensorUpdate( + title=None, + devices={ + None: SensorDeviceInfo( + name="IBS-TH EEFF", + model="IBS-TH", + manufacturer="INKBIRD", + sw_version=None, + hw_version=None, + ) + }, + entity_descriptions={ + DeviceKey(key="humidity", device_id=None): SensorDescription( + device_key=DeviceKey(key="humidity", device_id=None), + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=Units.PERCENTAGE, + ), + }, + entity_values={ + DeviceKey(key="humidity", device_id=None): SensorValue( + device_key=DeviceKey(key="humidity", device_id=None), + name="Humidity", + native_value=humidity, + ), + }, + ) + + async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( @@ -68,3 +115,50 @@ async def test_device_with_corrupt_name(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_polling_sensor(hass: HomeAssistant) -> None: + """Test setting up a device that needs polling.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AA:BB:CC:DD:EE:FF", + data={CONF_DEVICE_TYPE: "IBS-TH"}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + with patch( + "homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update(10.24), + ): + inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "10.24" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-TH EEFF Humidity" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" + + with patch( + "homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update(20.24), + ): + async_fire_time_changed(hass, dt_util.utcnow() + FALLBACK_POLL_INTERVAL) + inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "20.24" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/iometer/__init__.py b/tests/components/iometer/__init__.py index 9e48fb982b3..19fe2124f1f 100644 --- a/tests/components/iometer/__init__.py +++ b/tests/components/iometer/__init__.py @@ -1,13 +1,19 @@ """Tests for the IOmeter integration.""" +from unittest.mock import patch + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: - """Fixture for setting up the component.""" +async def setup_platform( + hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] +) -> MockConfigEntry: + """Fixture for setting up the IOmeter platform.""" config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.iometer.PLATFORMS", platforms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/iometer/conftest.py b/tests/components/iometer/conftest.py index ee45021952e..f8139c7c64c 100644 --- a/tests/components/iometer/conftest.py +++ b/tests/components/iometer/conftest.py @@ -54,4 +54,5 @@ def mock_config_entry() -> MockConfigEntry: title="IOmeter-1ISK0000000000", data={CONF_HOST: "10.0.0.2"}, unique_id="658c2b34-2017-45f2-a12b-731235f8bb97", + entry_id="01JQ6G5395176MAAWKAAPEZHV6", ) diff --git a/tests/components/iometer/snapshots/test_binary_sensor.ambr b/tests/components/iometer/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..38aab735a14 --- /dev/null +++ b/tests/components/iometer/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_attachment_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_attachment_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core attachment status', + 'platform': 'iometer', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'attachment_status', + 'unique_id': '01JQ6G5395176MAAWKAAPEZHV6_attachment_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_attachment_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'IOmeter-1ISK0000000000 Core attachment status', + }), + 'context': , + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_attachment_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_bridge_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_bridge_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core/Bridge connection status', + 'platform': 'iometer', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'connection_status', + 'unique_id': '01JQ6G5395176MAAWKAAPEZHV6_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_bridge_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'IOmeter-1ISK0000000000 Core/Bridge connection status', + }), + 'context': , + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_bridge_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/iometer/test_binary_sensor.py b/tests/components/iometer/test_binary_sensor.py new file mode 100644 index 00000000000..e007084567e --- /dev/null +++ b/tests/components/iometer/test_binary_sensor.py @@ -0,0 +1,135 @@ +"""Test the IOmeter binary sensors.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_iometer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test binary sensors.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_connection_status_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_iometer_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection status sensor.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_bridge_connection_status" + ).state + == STATE_ON + ) + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_iometer_client.get_current_status.return_value.device.core.connection_status = "disconnected" + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_bridge_connection_status" + ).state + == STATE_OFF + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_attachment_status_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_iometer_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection status sensor.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_ON + ) + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_iometer_client.get_current_status.return_value.device.core.attachment_status = "detached" + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_OFF + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_attachment_status_sensors_unkown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_iometer_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection status sensor.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_ON + ) + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_iometer_client.get_current_status.return_value.device.core.attachment_status = None + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_UNKNOWN + ) diff --git a/tests/components/iometer/test_init.py b/tests/components/iometer/test_init.py index 22a20b50c60..9d8eadc5079 100644 --- a/tests/components/iometer/test_init.py +++ b/tests/components/iometer/test_init.py @@ -6,10 +6,11 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from homeassistant.components.iometer.const import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import setup_integration +from . import setup_platform from tests.common import MockConfigEntry, async_fire_time_changed @@ -22,7 +23,8 @@ async def test_new_firmware_version( freezer: FrozenDateTimeFactory, ) -> None: """Test device registry integration.""" - await setup_integration(hass, mock_config_entry) + # await setup_integration(hass, mock_config_entry) + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, mock_config_entry.unique_id)} ) diff --git a/tests/components/jellyfin/fixtures/get-media-folders.json b/tests/components/jellyfin/fixtures/get-media-folders.json index ff87751a9da..f6b5c1e8d78 100644 --- a/tests/components/jellyfin/fixtures/get-media-folders.json +++ b/tests/components/jellyfin/fixtures/get-media-folders.json @@ -302,8 +302,6 @@ "Album": "string", "CollectionType": "tvshows", "DisplayOrder": "string", - "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", - "AlbumPrimaryImageTag": "string", "SeriesPrimaryImageTag": "string", "AlbumArtist": "string", "AlbumArtists": [ diff --git a/tests/components/jellyfin/fixtures/sessions.json b/tests/components/jellyfin/fixtures/sessions.json index 00a1f5265db..db2b691dff0 100644 --- a/tests/components/jellyfin/fixtures/sessions.json +++ b/tests/components/jellyfin/fixtures/sessions.json @@ -4346,6 +4346,7 @@ ], "Album": "ALBUM", "AlbumId": "ALBUM-UUID", + "AlbumPrimaryImageTag": "ALBUM-PRIMARY-IMAGE-TAG", "AlbumArtist": "Album Artist", "AlbumArtists": [ { "Name": "Album Artist", "Id": "9a65b2c222ddb34e51f5cae360fad3a1" } diff --git a/tests/components/jellyfin/fixtures/user-items-parent-id.json b/tests/components/jellyfin/fixtures/user-items-parent-id.json index 2e06c30894c..cd0232894bc 100644 --- a/tests/components/jellyfin/fixtures/user-items-parent-id.json +++ b/tests/components/jellyfin/fixtures/user-items-parent-id.json @@ -302,8 +302,6 @@ "Album": "string", "CollectionType": "string", "DisplayOrder": "string", - "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", - "AlbumPrimaryImageTag": "string", "SeriesPrimaryImageTag": "string", "AlbumArtist": "string", "AlbumArtists": [ diff --git a/tests/components/jellyfin/snapshots/test_diagnostics.ambr b/tests/components/jellyfin/snapshots/test_diagnostics.ambr index c992628f034..9d73ee6397c 100644 --- a/tests/components/jellyfin/snapshots/test_diagnostics.ambr +++ b/tests/components/jellyfin/snapshots/test_diagnostics.ambr @@ -1707,6 +1707,7 @@ }), ]), 'AlbumId': 'ALBUM-UUID', + 'AlbumPrimaryImageTag': 'ALBUM-PRIMARY-IMAGE-TAG', 'ArtistItems': list([ dict({ 'Id': '1d864900526d9a9513b489f1cc28f8ca', diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index 3263639a32f..c6f015e9bb4 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -27,6 +27,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_ICON, ) @@ -124,6 +125,10 @@ async def test_media_player_music( assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) is None assert state.attributes.get(ATTR_MEDIA_SEASON) is None assert state.attributes.get(ATTR_MEDIA_EPISODE) is None + assert ( + state.attributes.get(ATTR_ENTITY_PICTURE) + == "http://localhost/Items/ALBUM-UUID/Images/Primary.jpg" + ) entry = entity_registry.async_get(state.entity_id) assert entry diff --git a/tests/components/jewish_calendar/test_service.py b/tests/components/jewish_calendar/test_service.py new file mode 100644 index 00000000000..9eb80e5e7f0 --- /dev/null +++ b/tests/components/jewish_calendar/test_service.py @@ -0,0 +1,55 @@ +"""Test jewish calendar service.""" + +import datetime as dt + +from hdate.translator import Language +import pytest + +from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("test_date", "nusach", "language", "expected"), + [ + pytest.param(dt.date(2025, 3, 20), "sfarad", "he", "", id="no_blessing"), + pytest.param( + dt.date(2025, 5, 20), + "ashkenaz", + "he", + "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים בעומר", + id="ahskenaz-hebrew", + ), + pytest.param( + dt.date(2025, 5, 20), + "sfarad", + "en", + "Today is the thirty-seventh day, which are five weeks and two days of the Omer", + id="sefarad-english", + ), + ], +) +async def test_get_omer_blessing( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + test_date: dt.date, + nusach: str, + language: Language, + expected: str, +) -> None: + """Test get omer blessing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.services.async_call( + DOMAIN, + "count_omer", + {"date": test_date, "nusach": nusach, "language": language}, + blocking=True, + return_response=True, + ) + + assert result["message"] == expected diff --git a/tests/components/kitchen_sink/snapshots/test_init.ambr b/tests/components/kitchen_sink/snapshots/test_init.ambr index b91131eb2b0..fe22f19fb7a 100644 --- a/tests/components/kitchen_sink/snapshots/test_init.ambr +++ b/tests/components/kitchen_sink/snapshots/test_init.ambr @@ -48,5 +48,15 @@ 'type': 'no_state', }), ]), + 'sensor.statistics_issues_issue_5': list([ + dict({ + 'data': dict({ + 'metadata_mean_type': 1, + 'state_mean_type': 2, + 'statistic_id': 'sensor.statistics_issues_issue_5', + }), + 'type': 'mean_type_changed', + }), + ]), }) # --- diff --git a/tests/components/kitchen_sink/snapshots/test_sensor.ambr b/tests/components/kitchen_sink/snapshots/test_sensor.ambr index 7b433c40170..6cd9aa2e855 100644 --- a/tests/components/kitchen_sink/snapshots/test_sensor.ambr +++ b/tests/components/kitchen_sink/snapshots/test_sensor.ambr @@ -29,6 +29,20 @@ 'last_updated': , 'state': '1500', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_direction', + 'friendly_name': 'Statistics issues Issue 5', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Statistics issues Issue 1', @@ -99,6 +113,20 @@ 'last_updated': , 'state': '1500', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_direction', + 'friendly_name': 'Statistics issues Issue 5', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Sensor test', diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index 1eea1c8036b..88bacc2cb0b 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -96,6 +96,15 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options_1" + section_marker, section_schema = list(result["data_schema"].schema.items())[0] + assert section_marker == "section_1" + section_schema_markers = list(section_schema.schema.schema) + assert len(section_schema_markers) == 2 + assert section_schema_markers[0] == "bool" + assert section_schema_markers[0].description is None + assert section_schema_markers[1] == "int" + assert section_schema_markers[1].description == {"suggested_value": 10} + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"section_1": {"bool": True, "int": 15}}, diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 50518f89107..526801aecfa 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.statistics import ( + StatisticMeanType, async_add_external_statistics, get_last_statistics, list_statistic_ids, @@ -45,6 +46,7 @@ async def test_demo_statistics(hass: HomeAssistant) -> None: assert { "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": "Outdoor temperature", "source": DOMAIN, @@ -55,6 +57,7 @@ async def test_demo_statistics(hass: HomeAssistant) -> None: assert { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Energy consumption 1", "source": DOMAIN, diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index 5bc280535f9..8fcafcd05a4 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -31,7 +31,6 @@ async def test_integration_log_info( assert msg["type"] == TYPE_RESULT assert msg["success"] assert {"domain": "http", "level": logging.DEBUG} in msg["result"] - assert {"domain": "websocket_api", "level": logging.DEBUG} in msg["result"] async def test_integration_log_level_logger_not_loaded( diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index f35f7369f93..4c7cc96504b 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -13,6 +13,16 @@ from homeassistant.setup import async_setup_component from tests.typing import WebSocketGenerator +@pytest.fixture +def mock_onboarding_not_done() -> Generator[MagicMock]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ) as mock_onboarding: + yield mock_onboarding + + @pytest.fixture def mock_onboarding_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" @@ -23,6 +33,15 @@ def mock_onboarding_done() -> Generator[MagicMock]: yield mock_onboarding +@pytest.fixture +def mock_add_onboarding_listener() -> Generator[MagicMock]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_add_listener", + ) as mock_add_onboarding_listener: + yield mock_add_onboarding_listener + + async def test_create_dashboards_when_onboarded( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -41,6 +60,45 @@ async def test_create_dashboards_when_onboarded( assert response["result"] == [] +async def test_create_dashboards_when_not_onboarded( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + mock_add_onboarding_listener, + mock_onboarding_not_done, +) -> None: + """Test we automatically create dashboards when not onboarded.""" + client = await hass_ws_client(hass) + + assert await async_setup_component(hass, "lovelace", {}) + + # Call onboarding listener + mock_add_onboarding_listener.mock_calls[0][1][1]() + await hass.async_block_till_done() + + # List dashboards + await client.send_json_auto_id({"type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + { + "icon": "mdi:map", + "id": "map", + "mode": "storage", + "require_admin": False, + "show_in_sidebar": True, + "title": "Map", + "url_path": "map", + } + ] + + # List map dashboard config + await client.send_json_auto_id({"type": "lovelace/config", "url_path": "map"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"strategy": {"type": "map"}} + + @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") async def test_hass_data_compatibility( diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index d7429f6087d..a085a1e3540 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -104,6 +104,7 @@ async def integration_fixture( "pressure_sensor", "room_airconditioner", "silabs_dishwasher", + "silabs_evse_charging", "silabs_laundrywasher", "smoke_detector", "switch_unit", diff --git a/tests/components/matter/fixtures/nodes/generic_switch_multi.json b/tests/components/matter/fixtures/nodes/generic_switch_multi.json index 8923198c31e..4055c9dc336 100644 --- a/tests/components/matter/fixtures/nodes/generic_switch_multi.json +++ b/tests/components/matter/fixtures/nodes/generic_switch_multi.json @@ -72,7 +72,6 @@ "1/59/0": 2, "1/59/65533": 1, "1/59/1": 0, - "1/59/2": 2, "1/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/59/65532": 30, "1/59/65528": [], @@ -102,7 +101,7 @@ "2/59/0": 2, "2/59/65533": 1, "2/59/1": 0, - "2/59/2": 2, + "2/59/2": 4, "2/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "2/59/65532": 30, "2/59/65528": [], diff --git a/tests/components/matter/fixtures/nodes/silabs_evse_charging.json b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json new file mode 100644 index 00000000000..3188ba81ad6 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json @@ -0,0 +1,580 @@ +{ + "node_id": 23, + "date_commissioned": "2024-12-17T18:14:53.210190", + "last_interview": "2024-12-17T18:14:53.211611", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 44, 45, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "evse", + "0/40/4": 32769, + "0/40/5": "evse", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/15": "TEST_SN", + "0/40/18": "evse", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17039360, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 21, 22, 65528, 65529, 65531, + 65532, 65533 + ], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/8": [0], + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkI9NTnB", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBpw=="], + "6": [ + "KgEOCgKzOZCNB+q+Uz0I9w==", + "KgEOCgKzOZC/O1Ew1WvS4A==", + "/oAAAAAAAADml3Ozl7GZug==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 10129, + "0/51/8": true, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRFxgkBwEkCAEwCUEECp4PASYUFk/DwQqGNBikYdiBRDJZbrfF4AYK8Y9jOeIpx7Xy+giJhmTpAVZ662hwszsFDGULGY/owXtMrqTxEDcKNQEoARgkAgE2AwQCBAEYMAQUqBmxO16fPQhbf33Gb2XwQ+NkXpswBRTx8+4bdkuqlxInfB5LXkhRBBvS2hgwC0A8aefsLm663Vuy+TkSvn/oLhRqt2phrG+i5aM5o15xiWDjnNVdUYpT09+K0mgVoMdFuFsmoWQxQh6jahaFJzUgGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEGp55xGRB0FBQ3Yw7ayQSzVtYA0BtCJFm9vRRcdr+nk0cuGX6zrUowSYOO/qiRBEACcCNNSqKh+DpRm2uVLOtaDcKNQEpARgkAmAwBBTx8+4bdkuqlxInfB5LXkhRBBvS2jAFFIxTG68U5WQVsk8AtvSQyeK3KLqPGDALQIw/6q5ILMNdOMcSif8HNbEgpjBeaBMfUpzOJFCRPM16sv1xiq3mALZj0u+iG8lUJEvDJOFKPoBvsOubwIwRgAQY", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BMeyHMXjJpVWF9saehBu7pZLTwdopKZTl5JdhU0/ozZ/sk1paVFE1U8OtuZqM/S/4W/fnkCnUrQ/Xcs7Ddy0hPE=", + "2": 65521, + "3": 1, + "4": 23, + "5": "HA_test", + "254": 1 + }, + { + "1": "BBF47gm4BEBA6LXQluAHjn6P3+MZKrhuMcJligg1xcBM7X++F7GsZFh4hYAhdmD9HHwhtZxH2c85aAzbpikViwI=", + "2": 65521, + "3": 1, + "4": 100, + "5": "", + "254": 2 + } + ], + "0/62/2": 16, + "0/62/3": 2, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEx7IcxeMmlVYX2xp6EG7ulktPB2ikplOXkl2FTT+jNn+yTWlpUUTVTw625moz9L/hb9+eQKdStD9dyzsN3LSE8TcKNQEpARgkAmAwBBSMUxuvFOVkFbJPALb0kMnityi6jzAFFIxTG68U5WQVsk8AtvSQyeK3KLqPGDALQPBVUg+OBUWl1pe/k55ZigAZl3lfBP1Qd5zQP4AUB45mNTzdli8DRCj+h7cIs3JHQQPlUaRvG5xUoBZ+C7Gg2sQY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEEXjuCbgEQEDotdCW4AeOfo/f4xkquG4xwmWKCDXFwEztf74XsaxkWHiFgCF2YP0cfCG1nEfZzzloDNumKRWLAjcKNQEpARgkAmAwBBQD3rx0jOdkiCPt06hxW7Z2jJBPXTAFFAPevHSM52SII+3TqHFbtnaMkE9dGDALQL+L3Zc6En6Ionk6WIz+lM50iwOEzTi9VwyYQRUdtO99T8jRX52+Olh6zcUtWQuYO2XYiH2OZ8lM4guqqnS8U4UY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 1293, + "1": 1 + }, + { + "0": 1292, + "1": 1 + }, + { + "0": 1296, + "1": 1 + }, + { + "0": 17, + "1": 1 + } + ], + "1/29/1": [3, 29, 47, 144, 145, 152, 153, 156, 157, 159], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/47/0": 1, + "1/47/1": 0, + "1/47/2": "Primary Mains Power", + "1/47/5": 0, + "1/47/7": 230000, + "1/47/8": 32000, + "1/47/31": [1], + "1/47/65532": 1, + "1/47/65533": 3, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [0, 1, 2, 5, 7, 8, 31, 65528, 65529, 65531, 65532, 65533], + "1/144/0": 2, + "1/144/1": 3, + "1/144/2": [ + { + "0": 5, + "1": true, + "2": -50000000, + "3": 50000000, + "4": [ + { + "0": -50000000, + "1": -10000000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -9999999, + "1": 9999999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 10000000, + "1": 50000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": -100000, + "3": 100000, + "4": [ + { + "0": -100000, + "1": -5000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -4999, + "1": 4999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 5000, + "1": 100000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": -500000, + "3": 500000, + "4": [ + { + "0": -500000, + "1": -100000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -99999, + "1": 99999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 100000, + "1": 500000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "1/144/3": [], + "1/144/4": null, + "1/144/5": null, + "1/144/6": null, + "1/144/7": null, + "1/144/8": null, + "1/144/9": null, + "1/144/10": null, + "1/144/11": null, + "1/144/12": null, + "1/144/13": null, + "1/144/14": null, + "1/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "1/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "1/144/17": null, + "1/144/18": null, + "1/144/65532": 31, + "1/144/65533": 1, + "1/144/65528": [], + "1/144/65529": [], + "1/144/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 65528, + 65529, 65531, 65532, 65533 + ], + "1/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 1000000000000000, + "4": [ + { + "0": 98440650424323, + "1": 98442759724168, + "2": 0, + "3": 0, + "5": 140728898420739, + "6": 98440650424355 + } + ] + }, + "1/145/1": null, + "1/145/2": null, + "1/145/3": null, + "1/145/4": null, + "1/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "1/145/65532": 15, + "1/145/65533": 1, + "1/145/65528": [], + "1/145/65529": [], + "1/145/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "1/152/0": 0, + "1/152/1": false, + "1/152/2": 1, + "1/152/3": 1200000, + "1/152/4": 7600000, + "1/152/5": null, + "1/152/6": null, + "1/152/7": 0, + "1/152/65532": 123, + "1/152/65533": 4, + "1/152/65528": [], + "1/152/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/152/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "1/153/0": 3, + "1/153/1": 1, + "1/153/2": 0, + "1/153/3": null, + "1/153/5": 32000, + "1/153/6": 2000, + "1/153/7": 30000, + "1/153/9": 32000, + "1/153/10": 600, + "1/153/35": null, + "1/153/36": null, + "1/153/37": null, + "1/153/38": null, + "1/153/39": null, + "1/153/64": 2, + "1/153/65": 0, + "1/153/66": 0, + "1/153/65532": 9, + "1/153/65533": 3, + "1/153/65528": [0], + "1/153/65529": [1, 2, 5, 6, 7, 4], + "1/153/65531": [ + 0, 1, 2, 3, 5, 6, 7, 9, 10, 35, 36, 37, 38, 39, 64, 65, 66, 65528, 65529, + 65531, 65532, 65533 + ], + "1/156/65532": 1, + "1/156/65533": 1, + "1/156/65528": [], + "1/156/65529": [], + "1/156/65531": [65528, 65529, 65531, 65532, 65533], + "1/157/0": [ + { + "0": "Manual", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Auto-scheduled", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Solar", + "1": 2, + "2": [ + { + "1": 16386 + } + ] + }, + { + "0": "Auto-scheduled with Solar charging", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16386 + } + ] + } + ], + "1/157/1": 1, + "1/157/65532": 0, + "1/157/65533": 2, + "1/157/65528": [1], + "1/157/65529": [0], + "1/157/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/159/0": [ + { + "0": "No energy management (forecast only)", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Device optimizes (no local or grid control)", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Optimized within building", + "1": 2, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + } + ] + }, + { + "0": "Optimized for grid", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16387 + } + ] + }, + { + "0": "Optimized for grid and building", + "1": 4, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + }, + { + "1": 16387 + } + ] + } + ], + "1/159/1": 3, + "1/159/65532": 0, + "1/159/65533": 2, + "1/159/65528": [1], + "1/159/65529": [0], + "1/159/65531": [0, 1, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index c8de905d03f..ec5317ba808 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -383,6 +383,150 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_charging_status', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingStatusSensor-153-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'evse Charging status', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_plug_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvsePlugStateSensor-153-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'evse Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_supply_charging_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply charging state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_supply_charging_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseSupplyStateSensor-153-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'evse Supply charging state', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_supply_charging_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[smoke_detector][binary_sensor.smoke_sensor_battery_alert-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_event.ambr b/tests/components/matter/snapshots/test_event.ambr index b0ddfaed8bf..153f5751f14 100644 --- a/tests/components/matter/snapshots/test_event.ambr +++ b/tests/components/matter/snapshots/test_event.ambr @@ -132,6 +132,8 @@ 'event_types': list([ 'multi_press_1', 'multi_press_2', + 'multi_press_3', + 'multi_press_4', 'long_press', 'long_release', ]), @@ -172,6 +174,8 @@ 'event_types': list([ 'multi_press_1', 'multi_press_2', + 'multi_press_3', + 'multi_press_4', 'long_press', 'long_release', ]), diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index dc35f6f2a69..e1ee782cd3b 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -401,8 +401,8 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 25, - 'min': -25, + 'max': 50, + 'min': -50, 'mode': , 'step': 0.5, }), @@ -439,8 +439,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Eve Thermo Temperature offset', - 'max': 25, - 'min': -25, + 'max': 50, + 'min': -50, 'mode': , 'step': 0.5, 'unit_of_measurement': , @@ -483,7 +483,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Altitude above Sea Level', + 'original_name': 'Altitude above sea level', 'platform': 'matter', 'previous_unique_id': None, 'supported_features': 0, @@ -496,7 +496,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', - 'friendly_name': 'Eve Weather Altitude above Sea Level', + 'friendly_name': 'Eve Weather Altitude above sea level', 'max': 9000, 'min': 0, 'mode': , diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 772ee297e13..8ad579214d0 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1543,6 +1543,128 @@ 'state': 'previous', }) # --- +# name: test_selects[silabs_evse_charging][select.evse_energy_management_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.evse_energy_management_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Energy management mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_energy_management_mode', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-MatterDeviceEnergyManagementMode-159-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_energy_management_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Energy management mode', + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'context': , + 'entity_id': 'select.evse_energy_management_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Optimized for grid', + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Manual', + 'Auto-scheduled', + 'Solar', + 'Auto-scheduled with Solar charging', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.evse_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mode', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-MatterEnergyEvseMode-157-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Mode', + 'options': list([ + 'Manual', + 'Auto-scheduled', + 'Solar', + 'Auto-scheduled with Solar charging', + ]), + }), + 'context': , + 'entity_id': 'select.evse_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Auto-scheduled', + }) +# --- # name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 9caa84bbf96..b3395551d74 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -686,7 +686,7 @@ 'state': '20.0', }) # --- -# name: test_sensors[air_purifier][sensor.air_purifier_vocs-entry] +# name: test_sensors[air_purifier][sensor.air_purifier_volatile_organic_compounds_parts-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -701,7 +701,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.air_purifier_vocs', + 'entity_id': 'sensor.air_purifier_volatile_organic_compounds_parts', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -713,7 +713,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'VOCs', + 'original_name': 'Volatile organic compounds parts', 'platform': 'matter', 'previous_unique_id': None, 'supported_features': 0, @@ -722,16 +722,16 @@ 'unit_of_measurement': 'ppm', }) # --- -# name: test_sensors[air_purifier][sensor.air_purifier_vocs-state] +# name: test_sensors[air_purifier][sensor.air_purifier_volatile_organic_compounds_parts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'volatile_organic_compounds_parts', - 'friendly_name': 'Air Purifier VOCs', + 'friendly_name': 'Air Purifier Volatile organic compounds parts', 'state_class': , 'unit_of_measurement': 'ppm', }), 'context': , - 'entity_id': 'sensor.air_purifier_vocs', + 'entity_id': 'sensor.air_purifier_volatile_organic_compounds_parts', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1167,7 +1167,7 @@ 'state': '20.08', }) # --- -# name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_vocs-entry] +# name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_volatile_organic_compounds_parts-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1182,7 +1182,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_vocs', + 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_volatile_organic_compounds_parts', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1194,7 +1194,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'VOCs', + 'original_name': 'Volatile organic compounds parts', 'platform': 'matter', 'previous_unique_id': None, 'supported_features': 0, @@ -1203,16 +1203,16 @@ 'unit_of_measurement': 'ppm', }) # --- -# name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_vocs-state] +# name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_volatile_organic_compounds_parts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'volatile_organic_compounds_parts', - 'friendly_name': 'lightfi-aq1-air-quality-sensor VOCs', + 'friendly_name': 'lightfi-aq1-air-quality-sensor Volatile organic compounds parts', 'state_class': , 'unit_of_measurement': 'ppm', }), 'context': , - 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_vocs', + 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_volatile_organic_compounds_parts', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2866,6 +2866,323 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_evse_charging][sensor.evse_circuit_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_circuit_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Circuit capacity', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_circuit_capacity', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseCircuitCapacity-153-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_circuit_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Circuit capacity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_circuit_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'meter_failure', + 'over_voltage', + 'under_voltage', + 'over_current', + 'contact_wet_failure', + 'contact_dry_failure', + 'power_loss', + 'power_quality', + 'pilot_short_circuit', + 'emergency_stop', + 'ev_disconnected', + 'wrong_power_supply', + 'live_neutral_swap', + 'over_temperature', + 'other', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_fault_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fault state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_fault_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseFaultState-153-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'evse Fault state', + 'options': list([ + 'no_error', + 'meter_failure', + 'over_voltage', + 'under_voltage', + 'over_current', + 'contact_wet_failure', + 'contact_dry_failure', + 'power_loss', + 'power_quality', + 'pilot_short_circuit', + 'emergency_stop', + 'ev_disconnected', + 'wrong_power_supply', + 'live_neutral_swap', + 'over_temperature', + 'other', + ]), + }), + 'context': , + 'entity_id': 'sensor.evse_fault_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_max_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_max_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_max_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMaximumChargeCurrent-153-7', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_max_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Max charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_max_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_min_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_min_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Min charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_min_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMinimumChargeCurrent-153-6', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_min_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Min charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_min_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_user_max_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User max charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_user_max_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseUserMaximumChargeCurrent-153-9', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse User max charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_user_max_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.0', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index ebf43117846..d60a2933e6f 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -334,6 +334,53 @@ 'state': 'off', }) # --- +# name: test_switches[silabs_evse_charging][switch.evse_enable_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.evse_enable_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Enable charging', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_charging_switch', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingSwitch-153-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[silabs_evse_charging][switch.evse_enable_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Enable charging', + }), + 'context': , + 'entity_id': 'switch.evse_enable_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[switch_unit][switch.mock_switchunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index cddee975ac8..acd150d9131 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -147,3 +147,53 @@ async def test_optional_sensor_from_featuremap( ) state = hass.states.get(entity_id) assert state is None + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + # Test StateEnum value with binary_sensor.evse_charging_status + entity_id = "binary_sensor.evse_charging_status" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to PluggedInDemand state + set_node_attribute(matter_node, 1, 153, 0, 2) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/0", 2) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + # Test StateEnum value with binary_sensor.evse_plug + entity_id = "binary_sensor.evse_plug" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to NotPluggedIn state + set_node_attribute(matter_node, 1, 153, 0, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/0", 0) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + # Test SupplyStateEnum value with binary_sensor.evse_supply_charging + entity_id = "binary_sensor.evse_supply_charging_state" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to Disabled state + set_node_attribute(matter_node, 1, 153, 1, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/1", 0) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py index f3a318c4e8b..651c71a5dce 100644 --- a/tests/components/matter/test_event.py +++ b/tests/components/matter/test_event.py @@ -36,7 +36,7 @@ async def test_generic_switch_node( assert state assert state.state == "unknown" assert state.name == "Mock Generic Switch Button" - # check event_types from featuremap 30 + # check event_types from featuremap 14 (0b1110) assert state.attributes[ATTR_EVENT_TYPES] == [ "initial_press", "short_release", @@ -76,7 +76,7 @@ async def test_generic_switch_multi_node( assert state_button_1.state == "unknown" # name should be 'DeviceName Button (1)' due to the label set to just '1' assert state_button_1.name == "Mock Generic Switch Button (1)" - # check event_types from featuremap 14 + # check event_types from featuremap 30 (0b11110) and MultiPressMax unset (default 2) assert state_button_1.attributes[ATTR_EVENT_TYPES] == [ "multi_press_1", "multi_press_2", @@ -84,11 +84,20 @@ async def test_generic_switch_multi_node( "long_release", ] # check button 2 - state_button_1 = hass.states.get("event.mock_generic_switch_fancy_button") - assert state_button_1 - assert state_button_1.state == "unknown" + state_button_2 = hass.states.get("event.mock_generic_switch_fancy_button") + assert state_button_2 + assert state_button_2.state == "unknown" # name should be 'DeviceName Fancy Button' due to the label set to 'Fancy Button' - assert state_button_1.name == "Mock Generic Switch Fancy Button" + assert state_button_2.name == "Mock Generic Switch Fancy Button" + # check event_types from featuremap 30 (0b11110) and MultiPressMax 4 + assert state_button_2.attributes[ATTR_EVENT_TYPES] == [ + "multi_press_1", + "multi_press_2", + "multi_press_3", + "multi_press_4", + "long_press", + "long_release", + ] # trigger firing a multi press event await trigger_subscription_callback( diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 251aab73e3b..bcdb573b3c8 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -399,3 +399,71 @@ async def test_list_sensor( state = hass.states.get("sensor.laundrywasher_current_phase") assert state assert state.state == "rinse" + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + # EnergyEvseFaultState + state = hass.states.get("sensor.evse_fault_state") + assert state + assert state.state == "no_error" + + set_node_attribute(matter_node, 1, 153, 2, 4) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_fault_state") + assert state + assert state.state == "over_current" + + # EnergyEvseCircuitCapacity + state = hass.states.get("sensor.evse_circuit_capacity") + assert state + assert state.state == "32.0" + + set_node_attribute(matter_node, 1, 153, 5, 63000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_circuit_capacity") + assert state + assert state.state == "63.0" + + # EnergyEvseMinimumChargeCurrent + state = hass.states.get("sensor.evse_min_charge_current") + assert state + assert state.state == "2.0" + + set_node_attribute(matter_node, 1, 153, 6, 5000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_min_charge_current") + assert state + assert state.state == "5.0" + + # EnergyEvseMaximumChargeCurrent + state = hass.states.get("sensor.evse_max_charge_current") + assert state + assert state.state == "30.0" + + set_node_attribute(matter_node, 1, 153, 7, 20000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_max_charge_current") + assert state + assert state.state == "20.0" + + # EnergyEvseUserMaximumChargeCurrent + state = hass.states.get("sensor.evse_user_max_charge_current") + assert state + assert state.state == "32.0" + + set_node_attribute(matter_node, 1, 153, 9, 63000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_user_max_charge_current") + assert state + assert state.state == "63.0" diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index e82848fcc3a..f294cd31a26 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute @@ -188,3 +189,46 @@ async def test_matter_exception_on_command( }, blocking=True, ) + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + state = hass.states.get("switch.evse_enable_charging") + assert state + assert state.state == "on" + # test switch service + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.evse_enable_charging"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.EnergyEvse.Commands.Disable(), + timed_request_timeout_ms=3000, + ) + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.evse_enable_charging"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.EnergyEvse.Commands.EnableCharging( + chargingEnabledUntil=NullValue, + minimumChargeCurrent=0, + maximumChargeCurrent=0, + ), + timed_request_timeout_ms=3000, + ) diff --git a/tests/components/mcp/conftest.py b/tests/components/mcp/conftest.py index d86603a12ed..b6d6958d3d9 100644 --- a/tests/components/mcp/conftest.py +++ b/tests/components/mcp/conftest.py @@ -1,17 +1,34 @@ """Common fixtures for the Model Context Protocol tests.""" from collections.abc import Generator +import datetime from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.mcp.const import DOMAIN -from homeassistant.const import CONF_URL +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.mcp.const import ( + CONF_ACCESS_TOKEN, + CONF_AUTHORIZATION_URL, + CONF_TOKEN_URL, + DOMAIN, +) +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry TEST_API_NAME = "Memory Server" +MCP_SERVER_URL = "http://1.1.1.1:8080/sse" +CLIENT_ID = "test-client-id" +CLIENT_SECRET = "test-client-secret" +AUTH_DOMAIN = "some-auth-domain" +OAUTH_AUTHORIZE_URL = "https://example-auth-server.com/authorize-path" +OAUTH_TOKEN_URL = "https://example-auth-server.com/token-path" @pytest.fixture @@ -29,6 +46,7 @@ def mock_mcp_client() -> Generator[AsyncMock]: with ( patch("homeassistant.components.mcp.coordinator.sse_client"), patch("homeassistant.components.mcp.coordinator.ClientSession") as mock_session, + patch("homeassistant.components.mcp.coordinator.TIMEOUT", 1), ): yield mock_session.return_value.__aenter__ @@ -43,3 +61,47 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: ) config_entry.add_to_hass(hass) return config_entry + + +@pytest.fixture(name="credential") +async def mock_credential(hass: HomeAssistant) -> None: + """Fixture that provides the ClientCredential for the test.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + AUTH_DOMAIN, + ) + + +@pytest.fixture(name="config_entry_token_expiration") +def mock_config_entry_token_expiration() -> datetime.datetime: + """Fixture to mock the token expiration.""" + return datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) + + +@pytest.fixture(name="config_entry_with_auth") +def mock_config_entry_with_auth( + hass: HomeAssistant, + config_entry_token_expiration: datetime.datetime, +) -> MockConfigEntry: + """Fixture to load the integration with authentication.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=AUTH_DOMAIN, + data={ + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, + CONF_TOKEN_URL: OAUTH_TOKEN_URL, + CONF_TOKEN: { + CONF_ACCESS_TOKEN: "test-access-token", + "refresh_token": "test-refresh-token", + "expires_at": config_entry_token_expiration.timestamp(), + }, + }, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 29733e653a6..426b3267195 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -1,20 +1,70 @@ """Test the Model Context Protocol config flow.""" +import json from typing import Any from unittest.mock import AsyncMock, Mock import httpx import pytest +import respx from homeassistant import config_entries -from homeassistant.components.mcp.const import DOMAIN -from homeassistant.const import CONF_URL +from homeassistant.components.mcp.const import ( + CONF_AUTHORIZATION_URL, + CONF_TOKEN_URL, + DOMAIN, +) +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow -from .conftest import TEST_API_NAME +from .conftest import ( + AUTH_DOMAIN, + CLIENT_ID, + MCP_SERVER_URL, + OAUTH_AUTHORIZE_URL, + OAUTH_TOKEN_URL, + TEST_API_NAME, +) from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +MCP_SERVER_BASE_URL = "http://1.1.1.1:8080" +OAUTH_DISCOVERY_ENDPOINT = ( + f"{MCP_SERVER_BASE_URL}/.well-known/oauth-authorization-server" +) +OAUTH_SERVER_METADATA_RESPONSE = httpx.Response( + status_code=200, + text=json.dumps( + { + "authorization_endpoint": OAUTH_AUTHORIZE_URL, + "token_endpoint": OAUTH_TOKEN_URL, + } + ), +) +CALLBACK_PATH = "/auth/external/callback" +OAUTH_CALLBACK_URL = f"https://example.com{CALLBACK_PATH}" +OAUTH_CODE = "abcd" +OAUTH_TOKEN_PAYLOAD = { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, +} + + +def encode_state(hass: HomeAssistant, flow_id: str) -> str: + """Encode the OAuth JWT.""" + return config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": flow_id, + "redirect_uri": OAUTH_CALLBACK_URL, + }, + ) async def test_form( @@ -34,15 +84,19 @@ async def test_form( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } + # Config entry does not have a unique id + assert result["result"] + assert result["result"].unique_id is None + assert len(mock_setup_entry.mock_calls) == 1 @@ -73,7 +127,7 @@ async def test_form_mcp_client_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) @@ -89,50 +143,18 @@ async def test_form_mcp_client_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - ("side_effect", "expected_error"), - [ - ( - httpx.HTTPStatusError("", request=None, response=httpx.Response(401)), - "invalid_auth", - ), - ], -) -async def test_form_mcp_client_error_abort( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_mcp_client: Mock, - side_effect: Exception, - expected_error: str, -) -> None: - """Test we handle different client library errors that end with an abort.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - mock_mcp_client.side_effect = side_effect - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: "http://1.1.1.1/sse", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == expected_error - - @pytest.mark.parametrize( "user_input", [ @@ -165,14 +187,14 @@ async def test_input_form_validation_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } assert len(mock_setup_entry.mock_calls) == 1 @@ -183,7 +205,7 @@ async def test_unique_url( """Test that the same url cannot be configured twice.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={CONF_URL: "http://1.1.1.1/sse"}, + data={CONF_URL: MCP_SERVER_URL}, title=TEST_API_NAME, ) config_entry.add_to_hass(hass) @@ -201,7 +223,7 @@ async def test_unique_url( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) @@ -226,9 +248,409 @@ async def test_server_missing_capbilities( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "missing_capabilities" + + +@respx.mock +async def test_oauth_discovery_flow_without_credentials( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, +) -> None: + """Test for an OAuth discoveryflow for an MCP server where the user has not yet entered credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + + # The config flow will abort and the user will be taken to the application credentials UI + # to enter their credentials. + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_credentials" + + +async def perform_oauth_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + result: config_entries.ConfigFlowResult, + authorize_url: str = OAUTH_AUTHORIZE_URL, + token_url: str = OAUTH_TOKEN_URL, +) -> config_entries.ConfigFlowResult: + """Perform the common steps of the OAuth flow. + + Expects to be called from the step where the user selects credentials. + """ + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": OAUTH_CALLBACK_URL, + }, + ) + assert result["url"] == ( + f"{authorize_url}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={OAUTH_CALLBACK_URL}" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"{CALLBACK_PATH}?code={OAUTH_CODE}&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + token_url, + json=OAUTH_TOKEN_PAYLOAD, + ) + + return result + + +@pytest.mark.parametrize( + ("oauth_server_metadata_response", "expected_authorize_url", "expected_token_url"), + [ + (OAUTH_SERVER_METADATA_RESPONSE, OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL), + ( + httpx.Response( + status_code=200, + text=json.dumps( + { + "authorization_endpoint": "/authorize-path", + "token_endpoint": "/token-path", + } + ), + ), + f"{MCP_SERVER_BASE_URL}/authorize-path", + f"{MCP_SERVER_BASE_URL}/token-path", + ), + ( + httpx.Response(status_code=404), + f"{MCP_SERVER_BASE_URL}/authorize", + f"{MCP_SERVER_BASE_URL}/token", + ), + ], + ids=( + "discovery", + "relative_paths", + "no_discovery_metadata", + ), +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + oauth_server_metadata_response: httpx.Response, + expected_authorize_url: str, + expected_token_url: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=oauth_server_metadata_response + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + authorize_url=expected_authorize_url, + token_url=expected_token_url, + ) + + # Client now accepts credentials + mock_mcp_client.side_effect = None + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + data = result["data"] + token = data.pop(CONF_TOKEN) + assert data == { + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: expected_authorize_url, + CONF_TOKEN_URL: expected_token_url, + } + assert token + token.pop("expires_at") + assert token == OAUTH_TOKEN_PAYLOAD + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_oauth_discovery_failure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + side_effect: Exception, + expected_error: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(side_effect=side_effect) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow_server_failure_abort( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + side_effect: Exception, + expected_error: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + ) + + # Client fails with an error + mock_mcp_client.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow_server_missing_tool_capabilities( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + ) + + # Client can now authenticate + mock_mcp_client.side_effect = None + + response = Mock() + response.serverInfo.name = TEST_API_NAME + response.capabilities.tools = None + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_capabilities" + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + config_entry_with_auth: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + config_entry_with_auth.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + result = await perform_oauth_flow(hass, aioclient_mock, hass_client_no_auth, result) + + # Verify we can connect to the server + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry_with_auth.unique_id == AUTH_DOMAIN + assert config_entry_with_auth.title == TEST_API_NAME + data = {**config_entry_with_auth.data} + token = data.pop(CONF_TOKEN) + assert data == { + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, + CONF_TOKEN_URL: OAUTH_TOKEN_URL, + } + assert token + token.pop("expires_at") + assert token == OAUTH_TOKEN_PAYLOAD + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 460df2c5785..045fb99e181 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -76,17 +76,45 @@ async def test_init( assert config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("side_effect"), + [ + (httpx.TimeoutException("Some timeout")), + (httpx.HTTPStatusError("", request=None, response=httpx.Response(500))), + (httpx.HTTPStatusError("", request=None, response=httpx.Response(401))), + (httpx.HTTPError("Some HTTP error")), + ], +) async def test_mcp_server_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, + side_effect: Exception, ) -> None: """Test the integration fails to setup if the server fails initialization.""" + mock_mcp_client.side_effect = side_effect + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_mcp_server_authentication_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test the integration fails to setup if the server fails authentication.""" mock_mcp_client.side_effect = httpx.HTTPStatusError( - "", request=None, response=httpx.Response(500) + "Authentication required", request=None, response=httpx.Response(401) ) - with patch("homeassistant.components.mcp.coordinator.TIMEOUT", 1): - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state is ConfigEntryState.SETUP_RETRY + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" async def test_list_tools_failure( diff --git a/tests/components/meraki/test_device_tracker.py b/tests/components/meraki/test_device_tracker.py index 139396a0689..c187ca8ce75 100644 --- a/tests/components/meraki/test_device_tracker.py +++ b/tests/components/meraki/test_device_tracker.py @@ -1,6 +1,5 @@ """The tests the for Meraki device tracker.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json @@ -22,31 +21,25 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def meraki_client( - event_loop: AbstractEventLoop, +async def meraki_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Meraki mock client.""" - loop = event_loop + assert await async_setup_component( + hass, + device_tracker.DOMAIN, + { + device_tracker.DOMAIN: { + CONF_PLATFORM: "meraki", + CONF_VALIDATOR: "validator", + CONF_SECRET: "secret", + } + }, + ) + await hass.async_block_till_done() - async def setup_and_wait(): - result = await async_setup_component( - hass, - device_tracker.DOMAIN, - { - device_tracker.DOMAIN: { - CONF_PLATFORM: "meraki", - CONF_VALIDATOR: "validator", - CONF_SECRET: "secret", - } - }, - ) - await hass.async_block_till_done() - return result - - assert loop.run_until_complete(setup_and_wait()) - return loop.run_until_complete(hass_client()) + return await hass_client() async def test_invalid_or_missing_data( diff --git a/tests/components/meteo_france/conftest.py b/tests/components/meteo_france/conftest.py index eb28ec0a838..82b220e331e 100644 --- a/tests/components/meteo_france/conftest.py +++ b/tests/components/meteo_france/conftest.py @@ -24,8 +24,8 @@ def patch_requests(): mock_data.get_rain.return_value = Rain( load_json_object_fixture("raw_rain.json", DOMAIN) ) - mock_data.get_warning_current_phenomenoms.return_value = CurrentPhenomenons( - load_json_object_fixture("raw_warning_current_phenomenoms.json", DOMAIN) + mock_data.get_warning_current_phenomenons.return_value = CurrentPhenomenons( + load_json_object_fixture("raw_warning_current_phenomenons.json", DOMAIN) ) yield mock_data diff --git a/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json b/tests/components/meteo_france/fixtures/raw_warning_current_phenomenons.json similarity index 100% rename from tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json rename to tests/components/meteo_france/fixtures/raw_warning_current_phenomenons.json diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index 3b97c8aa7fe..b56b2c92678 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -7,6 +7,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" PORT = 23 +MAC = bytes.fromhex("c4dd57f8a55f") TVM_ZEROCONF_SERVICE_TYPE = "_tvm._tcp.local." diff --git a/tests/components/motionmount/conftest.py b/tests/components/motionmount/conftest.py index 49f624b5266..795495f4457 100644 --- a/tests/components/motionmount/conftest.py +++ b/tests/components/motionmount/conftest.py @@ -6,9 +6,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.motionmount.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT -from . import HOST, PORT, ZEROCONF_MAC, ZEROCONF_NAME +from . import HOST, MAC, PORT, ZEROCONF_MAC, ZEROCONF_NAME from tests.common import MockConfigEntry @@ -24,6 +24,17 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_with_pin() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=ZEROCONF_NAME, + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_PIN: 1234}, + unique_id=ZEROCONF_MAC, + ) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" @@ -34,12 +45,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_motionmount_config_flow() -> Generator[MagicMock]: +def mock_motionmount() -> Generator[MagicMock]: """Return a mocked MotionMount config flow.""" with patch( - "homeassistant.components.motionmount.config_flow.motionmount.MotionMount", + "homeassistant.components.motionmount.motionmount.MotionMount", autospec=True, ) as motionmount_mock: client = motionmount_mock.return_value + client.name = ZEROCONF_NAME + client.mac = MAC yield client diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py index 1fa2715595d..f6c5e8d8cc3 100644 --- a/tests/components/motionmount/test_config_flow.py +++ b/tests/components/motionmount/test_config_flow.py @@ -35,10 +35,10 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_user_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() user_input = MOCK_USER_INPUT.copy() @@ -54,10 +54,10 @@ async def test_user_connection_error( async def test_user_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when an invalid hostname is provided.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() user_input = MOCK_USER_INPUT.copy() @@ -73,10 +73,10 @@ async def test_user_connection_error_invalid_hostname( async def test_user_timeout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() user_input = MOCK_USER_INPUT.copy() @@ -92,10 +92,10 @@ async def test_user_timeout_error( async def test_user_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() user_input = MOCK_USER_INPUT.copy() @@ -111,13 +111,11 @@ async def test_user_not_connected_error( async def test_user_response_error_single_device_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") user_input = MOCK_USER_INPUT.copy() @@ -139,11 +137,11 @@ async def test_user_response_error_single_device_new_ce_old_pro( async def test_user_response_error_single_device_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -167,13 +165,13 @@ async def test_user_response_error_single_device_new_ce_new_pro( async def test_user_response_error_multi_device_new_ce_new_pro( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there are multiple devices.""" mock_config_entry.add_to_hass(hass) - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -190,14 +188,12 @@ async def test_user_response_error_multi_device_new_ce_new_pro( async def test_user_response_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) user_input = MOCK_USER_INPUT.copy() @@ -211,12 +207,8 @@ async def test_user_response_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -236,10 +228,10 @@ async def test_user_response_authentication_needed( async def test_zeroconf_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -255,10 +247,10 @@ async def test_zeroconf_connection_error( async def test_zeroconf_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -274,10 +266,10 @@ async def test_zeroconf_connection_error_invalid_hostname( async def test_zeroconf_timout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -293,10 +285,10 @@ async def test_zeroconf_timout_error( async def test_zeroconf_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -312,12 +304,10 @@ async def test_zeroconf_not_connected_error( async def test_show_zeroconf_form_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) result = await hass.config_entries.flow.async_init( @@ -348,10 +338,10 @@ async def test_show_zeroconf_form_new_ce_old_pro( async def test_show_zeroconf_form_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -383,7 +373,7 @@ async def test_show_zeroconf_form_new_ce_new_pro( async def test_zeroconf_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test we abort zeroconf flow if device already configured.""" mock_config_entry.add_to_hass(hass) @@ -402,13 +392,11 @@ async def test_zeroconf_device_exists_abort( async def test_zeroconf_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -421,12 +409,8 @@ async def test_zeroconf_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -448,17 +432,13 @@ async def test_zeroconf_authentication_needed( async def test_authentication_incorrect_then_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -483,9 +463,7 @@ async def test_authentication_incorrect_then_correct_pin( assert result["errors"][CONF_PIN] == CONF_PIN # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -505,18 +483,14 @@ async def test_authentication_incorrect_then_correct_pin( async def test_authentication_first_incorrect_pin_to_backoff( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - side_effect=[True, 1] - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(side_effect=[True, 1]) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -532,7 +506,7 @@ async def test_authentication_first_incorrect_pin_to_backoff( user_input=MOCK_PIN_INPUT.copy(), ) - assert mock_motionmount_config_flow.authenticate.called + assert mock_motionmount.authenticate.called assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backoff" @@ -541,12 +515,8 @@ async def test_authentication_first_incorrect_pin_to_backoff( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -567,16 +537,14 @@ async def test_authentication_first_incorrect_pin_to_backoff( async def test_authentication_multiple_incorrect_pins( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) user_input = MOCK_USER_INPUT.copy() @@ -602,12 +570,8 @@ async def test_authentication_multiple_incorrect_pins( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -628,16 +592,14 @@ async def test_authentication_multiple_incorrect_pins( async def test_authentication_show_backoff_when_still_running( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -671,12 +633,8 @@ async def test_authentication_show_backoff_when_still_running( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -697,17 +655,13 @@ async def test_authentication_show_backoff_when_still_running( async def test_authentication_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -720,9 +674,7 @@ async def test_authentication_correct_pin( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -741,11 +693,11 @@ async def test_authentication_correct_pin( async def test_full_user_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full manual user flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -773,11 +725,11 @@ async def test_full_user_flow_implementation( async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full zeroconf flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -808,7 +760,7 @@ async def test_full_zeroconf_flow_implementation( async def test_full_reauth_flow_implementation( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test reauthentication.""" mock_config_entry.add_to_hass(hass) @@ -824,12 +776,8 @@ async def test_full_reauth_flow_implementation( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), diff --git a/tests/components/motionmount/test_entity.py b/tests/components/motionmount/test_entity.py new file mode 100644 index 00000000000..e335c3a913b --- /dev/null +++ b/tests/components/motionmount/test_entity.py @@ -0,0 +1,47 @@ +"""Tests for the MotionMount Entity base.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import format_mac + +from . import ZEROCONF_NAME + +from tests.common import MockConfigEntry + + +async def test_entity_rename( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = True + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == ZEROCONF_NAME + + # Simulate the user changed the name of the device + mock_motionmount.name = "Blub" + + for callback in mock_motionmount.add_listener.call_args_list: + callback[0][0]() + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == "Blub" diff --git a/tests/components/motionmount/test_init.py b/tests/components/motionmount/test_init.py new file mode 100644 index 00000000000..e307945d0d0 --- /dev/null +++ b/tests/components/motionmount/test_init.py @@ -0,0 +1,129 @@ +"""Tests for the MotionMount init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.motionmount import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + + +async def test_setup_entry_with_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_without_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x00" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_failed_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.connect.side_effect = TimeoutError() + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_wrong_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x01" + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_no_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config_entry.async_get_active_flows(hass, sources={SOURCE_REAUTH})) + + +async def test_setup_entry_wrong_pin( + hass: HomeAssistant, + mock_config_entry_with_pin: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry_with_pin.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup( + mock_config_entry_with_pin.entry_id + ) + + assert mock_config_entry_with_pin.state is ConfigEntryState.SETUP_ERROR + assert any( + mock_config_entry_with_pin.async_get_active_flows(hass, sources={SOURCE_REAUTH}) + ) + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Test entries are unloaded correctly.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_motionmount.disconnect.call_count == 1 diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py index 0320e62d640..0132860727f 100644 --- a/tests/components/motionmount/test_sensor.py +++ b/tests/components/motionmount/test_sensor.py @@ -7,12 +7,10 @@ import pytest from homeassistant.core import HomeAssistant -from . import ZEROCONF_NAME +from . import MAC, ZEROCONF_NAME from tests.common import MockConfigEntry -MAC = bytes.fromhex("c4dd57f8a55f") - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index f000c4e0b9b..e4a368f0d71 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -70,9 +70,8 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { "363a7ecad6be4a19b939a016ea93e994": { "platform": "notify", "name": "Milkman alert", - "qos": 0, "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994", "retain": False, }, @@ -81,7 +80,6 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT2 = { "6494827dac294fa0827c54b02459d309": { "platform": "notify", "name": "The second notifier", - "qos": 0, "command_topic": "test-topic2", "entity_picture": "https://example.com/6494827dac294fa0827c54b02459d309", }, @@ -89,14 +87,58 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT2 = { MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = { "5269352dd9534c908d22812ea5d714cd": { "platform": "notify", - "qos": 0, "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd", "retain": False, }, } +MOCK_SUBENTRY_SENSOR_COMPONENT = { + "e9261f6feed443e7b7d5f3fbe2a47412": { + "platform": "sensor", + "name": "Energy", + "device_class": "enum", + "state_topic": "test-topic", + "options": ["low", "medium", "high"], + "expire_after": 30, + "value_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", + }, +} +MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS = { + "a0f85790a95d4889924602effff06b6e": { + "platform": "sensor", + "name": "Energy", + "state_class": "measurement", + "state_topic": "test-topic", + "entity_picture": "https://example.com/a0f85790a95d4889924602effff06b6e", + }, +} +MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = { + "e9261f6feed443e7b7d5f3fbe2a47412": { + "platform": "sensor", + "name": "Energy", + "state_class": "total", + "last_reset_value_template": "{{ value_json.value }}", + "state_topic": "test-topic", + "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", + }, +} +MOCK_SUBENTRY_SWITCH_COMPONENT = { + "3faf1318016c46c5aea26707eeb6f12e": { + "platform": "switch", + "name": "Outlet", + "device_class": "outlet", + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{ value }}", + "value_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/3faf1318016c46c5aea26707eeb6f12e", + "optimistic": True, + }, +} + # Bogus light component just for code coverage # Note that light cannot be setup through the UI yet # The test is for code coverage @@ -104,7 +146,6 @@ MOCK_SUBENTRY_LIGHT_COMPONENT = { "8131babc5e8d4f44b82e0761d39091a2": { "platform": "light", "name": "Test light", - "qos": 1, "command_topic": "test-topic4", "schema": "basic", "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", @@ -114,7 +155,6 @@ MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { "b10b531e15244425a74bb0abb1e9d2c6": { "platform": "notify", "name": "Test", - "qos": 1, "command_topic": "bad#topic", }, } @@ -148,10 +188,11 @@ MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { "model": "Model XL", "model_id": "mn002", "configuration_url": "https://example.com", + "mqtt_settings": {"qos": 1}, }, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, } -MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME = { +MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { "device": { "name": "Milk notifier", "sw_version": "1.0", @@ -162,7 +203,50 @@ MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME = { }, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, } - +MOCK_SENSOR_SUBENTRY_DATA_SINGLE = { + "device": { + "name": "Test sensor", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT, +} +MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS = { + "device": { + "name": "Test sensor", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS, +} +MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE = { + "device": { + "name": "Test sensor", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET, +} +MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS = { + "device": { + "name": "Test switch", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_SWITCH_COMPONENT, +} MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = { "device": { "name": "Milk notifier", @@ -185,7 +269,8 @@ MOCK_SUBENTRY_DATA_SET_MIX = { }, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2 - | MOCK_SUBENTRY_LIGHT_COMPONENT, + | MOCK_SUBENTRY_LIGHT_COMPONENT + | MOCK_SUBENTRY_SWITCH_COMPONENT, } | MOCK_SUBENTRY_AVAILABILITY_DATA _SENTINEL = object() diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 354cb33ba39..c94d692b374 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -18,6 +18,7 @@ from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED +from homeassistant.components.mqtt.util import learn_more_url from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( CONF_CLIENT_ID, @@ -33,8 +34,12 @@ from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .common import ( MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, - MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, + MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, ) from tests.common import MockConfigEntry, MockMqttReasonCode @@ -2612,54 +2617,176 @@ async def test_migrate_of_incompatible_config_entry( @pytest.mark.parametrize( ( "config_subentries_data", + "mock_device_user_input", "mock_entity_user_input", + "mock_entity_details_user_input", + "mock_entity_details_failed_user_input", "mock_mqtt_user_input", "mock_failed_mqtt_user_input", - "mock_failed_mqtt_user_input_errors", "entity_name", ), [ ( MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, {"name": "Milkman alert"}, + None, + None, { "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", - "qos": 0, + "command_template": "{{ value }}", "retain": False, }, - {"command_topic": "test-topic#invalid"}, - {"command_topic": "invalid_publish_topic"}, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ), "Milk notifier Milkman alert", ), ( - MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME, + MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {}, + None, + None, { "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", - "qos": 0, + "command_template": "{{ value }}", "retain": False, }, - {"command_topic": "test-topic#invalid"}, - {"command_topic": "invalid_publish_topic"}, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ), "Milk notifier", ), + ( + MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + {"name": "Test sensor", "mqtt_settings": {"qos": 0}}, + {"name": "Energy"}, + {"device_class": "enum", "options": ["low", "medium", "high"]}, + ( + ( + { + "device_class": "energy", + "unit_of_measurement": "ppm", + }, + {"unit_of_measurement": "invalid_uom"}, + ), + # Trigger options to be shown on the form + ( + {"device_class": "enum"}, + {"options": "options_with_enum_device_class"}, + ), + # Test options are only allowed with device_class enum + ( + { + "device_class": "energy", + "options": ["less", "more"], + }, + { + "device_class": "options_device_class_enum", + "unit_of_measurement": "uom_required_for_device_class", + }, + ), + # Include options again to allow flow with valid data + ( + {"device_class": "enum"}, + {"options": "options_with_enum_device_class"}, + ), + ( + { + "device_class": "enum", + "state_class": "measurement", + "options": ["less", "more"], + }, + {"options": "options_not_allowed_with_state_class_or_uom"}, + ), + ), + { + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "advanced_settings": {"expire_after": 30}, + }, + ( + ( + {"state_topic": "test-topic#invalid"}, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Test sensor Energy", + ), + ( + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, + {"name": "Test sensor", "mqtt_settings": {"qos": 0}}, + {"name": "Energy"}, + { + "state_class": "measurement", + }, + (), + { + "state_topic": "test-topic", + }, + (), + "Test sensor Energy", + ), + ( + MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, + {"name": "Test switch", "mqtt_settings": {"qos": 0}}, + {"name": "Outlet"}, + {"device_class": "outlet"}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "optimistic": True, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Test switch Outlet", + ), + ], + ids=[ + "notify_with_entity_name", + "notify_no_entity_name", + "sensor_options", + "sensor_total", + "switch", ], - ids=["notify_with_entity_name", "notify_no_entity_name"], ) async def test_subentry_configflow( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, config_subentries_data: dict[str, Any], + mock_device_user_input: dict[str, Any], mock_entity_user_input: dict[str, Any], + mock_entity_details_user_input: dict[str, Any], + mock_entity_details_failed_user_input: tuple[ + tuple[dict[str, Any], dict[str, str]], + ], mock_mqtt_user_input: dict[str, Any], - mock_failed_mqtt_user_input: dict[str, Any], - mock_failed_mqtt_user_input_errors: dict[str, Any], + mock_failed_mqtt_user_input: tuple[tuple[dict[str, Any], dict[str, str]],], entity_name: str, ) -> None: """Test the subentry ConfigFlow.""" - device_name = config_subentries_data["device"]["name"] + device_name = mock_device_user_input["name"] component = next(iter(config_subentries_data["components"].values())) await mqtt_mock_entry() @@ -2686,14 +2813,7 @@ async def test_subentry_configflow( result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={ - "name": device_name, - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + user_input=mock_device_user_input, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "entity" @@ -2723,23 +2843,55 @@ async def test_subentry_configflow( | mock_entity_user_input, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "mqtt_platform_config" assert result["errors"] == {} assert result["description_placeholders"] == { - "mqtt_device": "Milk notifier", - "platform": "notify", + "mqtt_device": device_name, + "platform": component["platform"], "entity": entity_name, + "url": learn_more_url(component["platform"]), } - # Process entity platform config flow + # Process extra step if the platform supports it + if mock_entity_details_user_input is not None: + # Extra entity details flow step + assert result["step_id"] == "entity_platform_config" - # Test an invalid mqtt user_input case - result = await hass.config_entries.subentries.async_configure( - result["flow_id"], - user_input=mock_failed_mqtt_user_input, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == mock_failed_mqtt_user_input_errors + # First test validators if set of test + for failed_user_input, failed_errors in mock_entity_details_failed_user_input: + # Test an invalid entity details user input case + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=failed_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == failed_errors + + # Now try again with valid data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=mock_entity_details_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["description_placeholders"] == { + "mqtt_device": device_name, + "platform": component["platform"], + "entity": entity_name, + "url": learn_more_url(component["platform"]), + } + else: + # No details form step + assert result["step_id"] == "mqtt_platform_config" + + # Process mqtt platform config flow + # Test an invalid mqtt user input case + for failed_user_input, failed_errors in mock_failed_mqtt_user_input: + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=failed_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == failed_errors # Try again with a valid configuration result = await hass.config_entries.subentries.async_configure( @@ -2756,6 +2908,10 @@ async def test_subentry_configflow( iter(config_subentries_data["components"].values()) ) + subentry_device_data = next(iter(config_entry.subentries.values())).data["device"] + for option, value in mock_device_user_input.items(): + assert subentry_device_data[option] == value + await hass.async_block_till_done() @@ -2799,8 +2955,12 @@ async def test_subentry_reconfigure_remove_entity( assert len(components) == 2 object_list = list(components) component_list = list(components.values()) - entity_name_0 = f"{device.name} {component_list[0]['name']}" - entity_name_1 = f"{device.name} {component_list[1]['name']}" + entity_name_0 = ( + f"{device.name} {component_list[0]['name']} ({component_list[0]['platform']})" + ) + entity_name_1 = ( + f"{device.name} {component_list[1]['name']} ({component_list[1]['platform']})" + ) for key, component in components.items(): unique_entity_id = f"{subentry_id}_{key}" @@ -2920,8 +3080,12 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( assert len(components) == 2 object_list = list(components) component_list = list(components.values()) - entity_name_0 = f"{device.name} {component_list[0]['name']}" - entity_name_1 = f"{device.name} {component_list[1]['name']}" + entity_name_0 = ( + f"{device.name} {component_list[0]['name']} ({component_list[0]['platform']})" + ) + entity_name_1 = ( + f"{device.name} {component_list[1]['name']} ({component_list[1]['platform']})" + ) for key in components: unique_entity_id = f"{subentry_id}_{key}" @@ -3000,7 +3164,13 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( @pytest.mark.parametrize( - ("mqtt_config_subentries_data", "user_input_mqtt"), + ( + "mqtt_config_subentries_data", + "user_input_platform_config_validation", + "user_input_platform_config", + "user_input_mqtt", + "removed_options", + ), [ ( ( @@ -3010,21 +3180,66 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( title="Mock subentry", ), ), + (), + None, { "command_topic": "test-topic1-updated", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "retain": True, }, - ) + {"entity_picture"}, + ), + ( + ( + ConfigSubentryData( + data=MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + ( + ( + { + "device_class": "battery", + "options": [], + "state_class": "measurement", + "unit_of_measurement": "invalid", + }, + # Allow to accept options are being removed + { + "device_class": "options_device_class_enum", + "options": "options_not_allowed_with_state_class_or_uom", + "unit_of_measurement": "invalid_uom", + }, + ), + ), + { + "device_class": "battery", + "state_class": "measurement", + "unit_of_measurement": "%", + "advanced_settings": {"suggested_display_precision": 1}, + }, + { + "state_topic": "test-topic1-updated", + "value_template": "{{ value_json.value }}", + }, + {"options", "expire_after", "entity_picture"}, + ), ], - ids=["notify"], + ids=["notify", "sensor"], ) async def test_subentry_reconfigure_edit_entity_single_entity( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + user_input_platform_config_validation: tuple[ + tuple[dict[str, Any], dict[str, str] | None], ... + ] + | None, + user_input_platform_config: dict[str, Any] | None, user_input_mqtt: dict[str, Any], + removed_options: tuple[str, ...], ) -> None: """Test the subentry ConfigFlow reconfigure with single entity.""" await mqtt_mock_entry() @@ -3081,7 +3296,28 @@ async def test_subentry_reconfigure_edit_entity_single_entity( user_input={}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "mqtt_platform_config" + + if user_input_platform_config is None: + # Skip entity flow step + assert result["step_id"] == "mqtt_platform_config" + else: + # Additional entity flow step + assert result["step_id"] == "entity_platform_config" + for entity_validation_config, errors in user_input_platform_config_validation: + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=entity_validation_config, + ) + assert result["step_id"] == "entity_platform_config" + assert result.get("errors") == errors + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_platform_config, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" # submit the new platform specific entity data, result = await hass.config_entries.subentries.async_configure( @@ -3110,6 +3346,142 @@ async def test_subentry_reconfigure_edit_entity_single_entity( for key, value in user_input_mqtt.items(): assert new_components[component_id][key] == value + assert set(component) - set(new_components[component_id]) == removed_options + + +@pytest.mark.parametrize( + ( + "mqtt_config_subentries_data", + "user_input_entity_details", + "user_input_mqtt", + "filtered_out_fields", + ), + [ + ( + ( + ConfigSubentryData( + data=MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE, + subentry_type="device", + title="Mock subentry", + ), + ), + { + "state_class": "measurement", + }, + { + "state_topic": "test-topic", + }, + ("last_reset_value_template",), + ), + ], + ids=["sensor_last_reset_template"], +) +async def test_subentry_reconfigure_edit_entity_reset_fields( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_entity_details: dict[str, Any], + user_input_mqtt: dict[str, Any], + filtered_out_fields: tuple[str, ...], +) -> None: + """Test the subentry ConfigFlow reconfigure resets filtered out fields.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for the subentry component + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 1 + + component_id, component = next(iter(components.items())) + for field in filtered_out_fields: + assert field in component + + unique_entity_id = f"{subentry_id}_{component_id}" + entity_id = entity_registry.async_get_entity_id( + domain=component["platform"], platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we do not have the option to delete an entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + "availability", + ] + + # assert we can update the entity, there is no select step + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "update_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the new common entity data, reset entity_picture + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity_platform_config" + + # submit the new entity platform config + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_entity_details, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific mqtt data, + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have out components + new_components = deepcopy(dict(subentry.data))["components"] + assert len(new_components) == 1 + + # Check our update was successful + assert "entity_picture" not in new_components[component_id] + + # Check the second component was updated + for key, value in user_input_mqtt.items(): + assert new_components[component_id][key] == value + + # Check field are filtered out correctly + for field in filtered_out_fields: + assert field not in new_components[component_id] + @pytest.mark.parametrize( ("mqtt_config_subentries_data", "user_input_entity", "user_input_mqtt"), @@ -3129,7 +3501,6 @@ async def test_subentry_reconfigure_edit_entity_single_entity( }, { "command_topic": "test-topic2", - "qos": 0, }, ) ], diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index b3a1c11c2b6..e2cc801e97d 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -1545,3 +1545,109 @@ async def test_rgb_value_template_fails( "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" in caplog.text ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "effect_list": ["rainbow", "colorloop"], + "state_topic": "test-topic", + "state_template": "{{ value_json.state }}", + "brightness_template": "{{ value_json.brightness }}", + "color_temp_template": "{{ value_json.color_temp }}", + "red_template": "{{ value_json.color.red }}", + "green_template": "{{ value_json.color.green }}", + "blue_template": "{{ value_json.color.blue }}", + "effect_template": "{{ value_json.effect }}", + }, + ), + ) + ], +) +async def test_state_templates_ignore_missing_values( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test that rendering of MQTT value template ignores missing values.""" + await mqtt_mock_entry() + + # turn on the light + async_fire_mqtt_message(hass, "test-topic", '{"state": "on"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + + # update brightness and color temperature (with no state) + async_fire_mqtt_message( + hass, "test-topic", '{"brightness": 255, "color_temp": 145}' + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == ( + 246, + 244, + 255, + ) # temp converted to color + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") == 6896 + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") == (0.317, 0.317) # temp converted to color + assert state.attributes.get("hs_color") == ( + 251.249, + 4.253, + ) # temp converted to color + + # update color + async_fire_mqtt_message( + hass, "test-topic", '{"color": {"red": 255, "green": 128, "blue": 64}}' + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") is None + + # update brightness + async_fire_mqtt_message(hass, "test-topic", '{"brightness": 128}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") is None + + # update effect + async_fire_mqtt_message(hass, "test-topic", '{"effect": "rainbow"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "rainbow" + + # invalid effect + async_fire_mqtt_message(hass, "test-topic", '{"effect": "invalid"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "rainbow" + + # turn off the light + async_fire_mqtt_message(hass, "test-topic", '{"state": "off"}') + state = hass.states.get("light.test") + assert state.state == STATE_OFF + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 2049dec0437..fa30283962b 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -1,7 +1,7 @@ """The tests for shared code of the MQTT platform.""" from typing import Any -from unittest.mock import patch +from unittest.mock import call, patch import pytest @@ -21,7 +21,11 @@ from homeassistant.helpers import ( ) from homeassistant.util import slugify -from .common import MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA, MOCK_SUBENTRY_DATA_SET_MIX +from .common import ( + MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA, + MOCK_SUBENTRY_DATA_SET_MIX, +) from tests.common import MockConfigEntry, async_capture_events, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator @@ -547,3 +551,39 @@ async def test_loading_subentry_with_bad_component_schema( "Schema violation occurred when trying to set up entity from subentry" in caplog.text ) + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_qos_on_mqt_device_from_subentry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_config_subentries_data: tuple[dict[str, Any]], + device_registry: dr.DeviceRegistry, +) -> None: + """Test QoS is set correctly on entities from MQTT device.""" + mqtt_mock = await mqtt_mock_entry() + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id = next(iter(entry.subentries)) + # Each subentry has one device + device = device_registry.async_get_device({("mqtt", subentry_id)}) + assert device is not None + assert hass.states.get("notify.milk_notifier_milkman_alert") is not None + await hass.services.async_call( + "notify", + "send_message", + {"entity_id": "notify.milk_notifier_milkman_alert", "message": "Test message"}, + ) + await hass.async_block_till_done() + assert len(mqtt_mock.async_publish.mock_calls) == 1 + mqtt_mock.async_publish.mock_calls[0] = call("test-topic", "Test message", 1, False) diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index d70d7dd792b..87eb381db03 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -1,6 +1,7 @@ """The tests for mqtt update component.""" import json +from typing import Any from unittest.mock import patch import pytest @@ -225,6 +226,71 @@ async def test_value_template( assert state.attributes.get("latest_version") == "2.0.0" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": "test/update", + "value_template": ( + "{\"latest_version\":\"{{ value_json['update']['latest_version'] }}\"," + "\"installed_version\":\"{{ value_json['update']['installed_version'] }}\"," + "\"update_percentage\":{{ value_json['update'].get('progress', 'null') }}}" + ), + "name": "Test Update", + } + } + } + ], +) +async def test_errornous_value_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that it fetches the given payload with a template or handles the exception.""" + state_topic = "test/update" + await mqtt_mock_entry() + + # Simulate a template redendering error with payload + # without "update" mapping + example_payload: dict[str, Any] = { + "child_lock": "UNLOCK", + "current": 0.02, + "energy": 212.92, + "indicator_mode": "off/on", + "linkquality": 65, + "power": 0, + "power_outage_memory": "off", + "state": "ON", + "voltage": 232, + } + + async_fire_mqtt_message(hass, state_topic, json.dumps(example_payload)) + await hass.async_block_till_done() + assert hass.states.get("update.test_update") is not None + assert "Unable to process payload '" in caplog.text + + # Add update info + example_payload["update"] = { + "latest_version": "2.0.0", + "installed_version": "1.9.0", + "progress": 20, + } + + async_fire_mqtt_message(hass, state_topic, json.dumps(example_payload)) + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state is not None + + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + assert state.attributes.get("update_percentage") == 20 + + @pytest.mark.parametrize( "hass_config", [ diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 8a08a55dc45..e8978f17f86 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -34,12 +34,16 @@ "needs_poll": false, "poll_interval": 30, "enabled": true, - "hidden": false, "icon": "mdi-speaker", "group_volume": 20, "display_name": "Test Player 1", - "extra_data": {}, - "announcement_in_progress": false + "power_control": "native", + "volume_control": "native", + "mute_control": "native", + "hide_player_in_ui": ["when_unavailable"], + "expose_to_ha": true, + "can_group_with": ["00:00:00:00:00:02"], + "source_list": [] }, { "player_id": "00:00:00:00:00:02", @@ -83,15 +87,27 @@ }, "synced_to": null, "enabled_by_default": true, - "needs_poll": false, - "poll_interval": 30, "enabled": true, "hidden": false, "icon": "mdi-speaker", "group_volume": 20, "display_name": "My Super Test Player 2", - "extra_data": {}, - "announcement_in_progress": false + "power_control": "native", + "volume_control": "native", + "mute_control": "native", + "hide_player_in_ui": ["when_unavailable"], + "expose_to_ha": true, + "can_group_with": ["00:00:00:00:00:01"], + "source_list": [ + { + "id": "spotify", + "name": "Spotify Connect", + "passive": true, + "can_play_pause": false, + "can_seek": false, + "can_next_previous": false + } + ] }, { "player_id": "test_group_player_1", @@ -135,15 +151,17 @@ }, "synced_to": null, "enabled_by_default": true, - "needs_poll": true, - "poll_interval": 30, "enabled": true, - "hidden": false, "icon": "mdi-speaker-multiple", "group_volume": 6, "display_name": "Test Group Player 1", - "extra_data": {}, - "announcement_in_progress": false + "power_control": "native", + "volume_control": "native", + "mute_control": "native", + "hide_player_in_ui": ["when_unavailable"], + "expose_to_ha": true, + "can_group_with": [], + "source_list": [] } ] } diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 44317d4977a..ad321a1cc29 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -694,19 +694,6 @@ async def test_media_player_supported_features( assert state assert state.attributes["supported_features"] == expected_features - # remove pause capability from player, trigger subscription callback - # and check if the supported features got updated - music_assistant_client.players._players[mass_player_id].supported_features.remove( - PlayerFeature.PAUSE - ) - await trigger_subscription_callback( - hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id - ) - expected_features &= ~MediaPlayerEntityFeature.PAUSE - state = hass.states.get(entity_id) - assert state - assert state.attributes["supported_features"] == expected_features - # remove grouping capability from player, trigger subscription callback # and check if the supported features got updated music_assistant_client.players._players[mass_player_id].supported_features.remove( diff --git a/tests/components/nam/test_button.py b/tests/components/nam/test_button.py index 39c37d57f89..b410665911a 100644 --- a/tests/components/nam/test_button.py +++ b/tests/components/nam/test_button.py @@ -2,9 +2,20 @@ from unittest.mock import patch -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass +from aiohttp.client_exceptions import ClientError +from nettigo_air_monitor import ApiError, AuthFailedError +import pytest + +from homeassistant.components.button import ( + DOMAIN as BUTTON_DOMAIN, + SERVICE_PRESS, + ButtonDeviceClass, +) +from homeassistant.components.nam import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -38,7 +49,7 @@ async def test_button_press(hass: HomeAssistant) -> None: ): await hass.services.async_call( BUTTON_DOMAIN, - "press", + SERVICE_PRESS, {ATTR_ENTITY_ID: "button.nettigo_air_monitor_restart"}, blocking=True, ) @@ -49,3 +60,55 @@ async def test_button_press(hass: HomeAssistant) -> None: state = hass.states.get("button.nettigo_air_monitor_restart") assert state assert state.state == now.isoformat() + + +@pytest.mark.parametrize(("exc"), [ApiError("API Error"), ClientError]) +async def test_button_press_exc(hass: HomeAssistant, exc: Exception) -> None: + """Test button press when exception occurs.""" + await init_integration(hass) + + with ( + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_restart", + side_effect=exc, + ), + pytest.raises( + HomeAssistantError, + match="An error occurred while calling action for button.nettigo_air_monitor_restart", + ), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.nettigo_air_monitor_restart"}, + blocking=True, + ) + + +async def test_button_press_auth_error(hass: HomeAssistant) -> None: + """Test button press when auth error occurs.""" + entry = await init_integration(hass) + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_restart", + side_effect=AuthFailedError("auth error"), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.nettigo_air_monitor_restart"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/namecheapdns/test_init.py b/tests/components/namecheapdns/test_init.py index 1d5b4ca5949..b7c1fe732c0 100644 --- a/tests/components/namecheapdns/test_init.py +++ b/tests/components/namecheapdns/test_init.py @@ -18,7 +18,7 @@ PASSWORD = "abcdefgh" @pytest.fixture -def setup_namecheapdns( +async def setup_namecheapdns( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Fixture that sets up NamecheapDNS.""" @@ -28,12 +28,10 @@ def setup_namecheapdns( text="0", ) - hass.loop.run_until_complete( - async_setup_component( - hass, - namecheapdns.DOMAIN, - {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, - ) + await async_setup_component( + hass, + namecheapdns.DOMAIN, + {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, ) diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 92d90a18a7e..b4b94efce5b 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -144,13 +144,14 @@ async def auth( return FakeAuth(aioclient_mock, create_device, device_access_project_id) -@pytest.fixture(autouse=True) -def cleanup_media_storage(hass: HomeAssistant) -> Generator[None]: +@pytest.fixture(autouse=True, name="media_path") +def cleanup_media_storage(hass: HomeAssistant) -> Generator[str]: """Test cleanup, remove any media storage persisted during the test.""" tmp_path = str(uuid.uuid4()) with patch("homeassistant.components.nest.media_source.MEDIA_PATH", new=tmp_path): - yield - shutil.rmtree(hass.config.path(tmp_path), ignore_errors=True) + full_path = hass.config.path(tmp_path) + yield full_path + shutil.rmtree(full_path, ignore_errors=True) @pytest.fixture diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index d009e1185da..0b0654fc69c 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -8,11 +8,13 @@ from collections.abc import Generator import datetime from http import HTTPStatus import io +import pathlib from typing import Any from unittest.mock import patch import aiohttp import av +from freezegun import freeze_time import numpy as np import pytest @@ -39,7 +41,7 @@ from .common import ( ) from .conftest import FakeAuth -from tests.common import MockUser, async_capture_events +from tests.common import MockUser, async_capture_events, async_fire_time_changed from tests.typing import ClientSessionGenerator DOMAIN = "nest" @@ -1574,3 +1576,80 @@ async def test_event_clip_media_attachment( response = await client.get(content_path) assert response.status == HTTPStatus.OK, f"Response not matched: {response}" await response.read() + + +@pytest.mark.parametrize(("device_traits", "cache_size"), [(BATTERY_CAMERA_TRAITS, 5)]) +async def test_remove_stale_media( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + auth, + mp4, + hass_client: ClientSessionGenerator, + subscriber, + setup_platform, + media_path: str, +) -> None: + """Test media files getting evicted from the cache.""" + await setup_platform() + + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Publish a media event + auth.responses = [ + aiohttp.web.Response(body=mp4.getvalue()), + ] + event_timestamp = dt_util.now() + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(MOTION_EVENT), + timestamp=event_timestamp, + ) + ) + await hass.async_block_till_done() + + # The first subdirectory is the device id. Media for events are stored in the + # device subdirectory. First verify that the media was persisted. We will + # then add additional media files, then invoke the garbage collector, and + # then verify orphaned files are removed. + storage_path = pathlib.Path(media_path) + device_path = storage_path / device.id + media_files = list(device_path.glob("*")) + assert len(media_files) == 1 + event_media = media_files[0] + assert event_media.name.endswith(".mp4") + + event_time1 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=8) + extra_media1 = ( + device_path / f"{int(event_time1.timestamp())}-camera_motion-test.mp4" + ) + extra_media1.write_bytes(mp4.getvalue()) + event_time2 = event_time1 + datetime.timedelta(hours=20) + extra_media2 = ( + device_path / f"{int(event_time2.timestamp())}-camera_motion-test.jpg" + ) + extra_media2.write_bytes(mp4.getvalue()) + # This event will not be garbage collected because it is too recent + event_time3 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=3) + extra_media3 = ( + device_path / f"{int(event_time3.timestamp())}-camera_motion-test.mp4" + ) + extra_media3.write_bytes(mp4.getvalue()) + + assert len(list(device_path.glob("*"))) == 4 + + # Advance the clock to invoke the garbage collector. This will remove extra + # files that are not valid events that are old enough. + point_in_time = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) + with freeze_time(point_in_time): + async_fire_time_changed(hass, point_in_time) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify that the event media is still present and that the extra files + # are removed. Newer media is not removed. + assert event_media.exists() + assert not extra_media1.exists() + assert not extra_media2.exists() + assert extra_media3.exists() diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index b149e80fa5b..00285f565a6 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -1501,7 +1501,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1520,7 +1520,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -1535,10 +1535,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Home avg Gust angle', 'latitude': 32.17901225, 'longitude': -117.17901225, - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -1659,60 +1660,6 @@ 'state': '63.2', }) # --- -# name: test_entity[sensor.home_avg_none-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_avg_none', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Home-avg-windangle_value', - 'unit_of_measurement': '°', - }) -# --- -# name: test_entity[sensor.home_avg_none-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Home avg None', - 'latitude': 32.17901225, - 'longitude': -117.17901225, - 'state_class': , - 'unit_of_measurement': '°', - }), - 'context': , - 'entity_id': 'sensor.home_avg_none', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17.0', - }) -# --- # name: test_entity[sensor.home_avg_precipitation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1939,6 +1886,61 @@ 'state': '22.7', }) # --- +# name: test_entity[sensor.home_avg_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_avg_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', + 'friendly_name': 'Home avg Wind direction', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.home_avg_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- # name: test_entity[sensor.home_avg_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2061,7 +2063,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2080,7 +2082,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -2095,10 +2097,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Home max Gust angle', 'latitude': 32.17901225, 'longitude': -117.17901225, - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -2219,60 +2222,6 @@ 'state': '76', }) # --- -# name: test_entity[sensor.home_max_none-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_max_none', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Home-max-windangle_value', - 'unit_of_measurement': '°', - }) -# --- -# name: test_entity[sensor.home_max_none-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Home max None', - 'latitude': 32.17901225, - 'longitude': -117.17901225, - 'state_class': , - 'unit_of_measurement': '°', - }), - 'context': , - 'entity_id': 'sensor.home_max_none', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', - }) -# --- # name: test_entity[sensor.home_max_precipitation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2499,6 +2448,61 @@ 'state': '27.4', }) # --- +# name: test_entity[sensor.home_max_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_max_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', + 'friendly_name': 'Home max Wind direction', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.home_max_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- # name: test_entity[sensor.home_max_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2621,7 +2625,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2640,7 +2644,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -2655,10 +2659,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Home min Gust angle', 'latitude': 32.17901225, 'longitude': -117.17901225, - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -2779,60 +2784,6 @@ 'state': '56', }) # --- -# name: test_entity[sensor.home_min_none-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_min_none', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Home-min-windangle_value', - 'unit_of_measurement': '°', - }) -# --- -# name: test_entity[sensor.home_min_none-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Home min None', - 'latitude': 32.17901225, - 'longitude': -117.17901225, - 'state_class': , - 'unit_of_measurement': '°', - }), - 'context': , - 'entity_id': 'sensor.home_min_none', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', - }) -# --- # name: test_entity[sensor.home_min_precipitation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3059,6 +3010,61 @@ 'state': '19.8', }) # --- +# name: test_entity[sensor.home_min_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_min_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-min-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_min_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', + 'friendly_name': 'Home min Wind direction', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.home_min_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- # name: test_entity[sensor.home_min_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6253,7 +6259,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -6272,7 +6278,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -6287,8 +6293,9 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Villa Garden Gust angle', - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -6524,7 +6531,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -6543,7 +6550,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Wind angle', 'platform': 'netatmo', @@ -6558,8 +6565,9 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Villa Garden Wind angle', - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 5fdf4f8ea35..c1a687c6fa8 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -25,11 +25,7 @@ from .common import ( simulate_webhook, ) -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_get_persistent_notifications, -) +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.cloud import mock_cloud from tests.typing import WebSocketGenerator @@ -423,9 +419,8 @@ async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) - notifications = async_get_persistent_notifications(hass) - - assert len(notifications) > 0 + # Test a reauth flow is initiated + assert len(list(config_entry.async_get_active_flows(hass, {"reauth"}))) == 1 for config_entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(config_entry.entry_id) @@ -476,8 +471,9 @@ async def test_setup_component_invalid_token( assert config_entry.state is ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) - notifications = async_get_persistent_notifications(hass) - assert len(notifications) > 0 + + # Test a reauth flow is initiated + assert len(list(config_entry.async_get_active_flows(hass, {"reauth"}))) == 1 for entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/network/snapshots/test_init.ambr b/tests/components/network/snapshots/test_init.ambr new file mode 100644 index 00000000000..268c8e0d44f --- /dev/null +++ b/tests/components/network/snapshots/test_init.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_repair_docker_host_network_without_host_networking[mock_socket0] + IssueRegistryItemSnapshot({ + 'active': True, + 'breaks_in_ha_version': None, + 'created': , + 'data': None, + 'dismissed_version': None, + 'domain': 'network', + 'is_fixable': False, + 'is_persistent': False, + 'issue_domain': None, + 'issue_id': 'docker_host_network', + 'learn_more_url': 'https://www.home-assistant.io/installation/linux#install-home-assistant-container', + 'severity': , + 'translation_key': 'docker_host_network', + 'translation_placeholders': dict({ + 'docs_url': 'https://docs.docker.com/network/network-tutorial-host/', + 'install_url': 'https://www.home-assistant.io/installation/linux#install-home-assistant-container', + }), + }) +# --- diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index a2352e6af9e..372dba1772d 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -1,10 +1,13 @@ """Test the Network Configuration.""" +from __future__ import annotations + from ipaddress import IPv4Address from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import network from homeassistant.components.network.const import ( @@ -17,6 +20,7 @@ from homeassistant.components.network.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR @@ -801,3 +805,48 @@ async def test_websocket_network_url( "external": None, "cloud": None, } + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_not_docker( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test repair is not created when not in Docker.""" + with patch("homeassistant.util.package.is_docker_env", return_value=False): + assert await async_setup_component(hass, "network", {}) + + assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_with_host_networking( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test repair is not created when in Docker with host networking.""" + with ( + patch("homeassistant.util.package.is_docker_env", return_value=True), + patch("homeassistant.components.network.Path.exists", return_value=True), + ): + assert await async_setup_component(hass, "network", {}) + + assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_without_host_networking( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test repair is created when in Docker without host networking.""" + with ( + patch("homeassistant.util.package.is_docker_env", return_value=True), + patch("homeassistant.components.network.Path.exists", return_value=False), + ): + assert await async_setup_component(hass, "network", {}) + + assert (issue := issue_registry.async_get_issue(DOMAIN, "docker_host_network")) + assert issue == snapshot diff --git a/tests/components/no_ip/test_init.py b/tests/components/no_ip/test_init.py index e344b984e7d..4e9c5d67c74 100644 --- a/tests/components/no_ip/test_init.py +++ b/tests/components/no_ip/test_init.py @@ -22,22 +22,20 @@ USERNAME = "abc@123.com" @pytest.fixture -def setup_no_ip(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_no_ip(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Fixture that sets up NO-IP.""" aioclient_mock.get(UPDATE_URL, params={"hostname": DOMAIN}, text="good 0.0.0.0") - hass.loop.run_until_complete( - async_setup_component( - hass, - no_ip.DOMAIN, - { - no_ip.DOMAIN: { - "domain": DOMAIN, - "username": USERNAME, - "password": PASSWORD, - } - }, - ) + await async_setup_component( + hass, + no_ip.DOMAIN, + { + no_ip.DOMAIN: { + "domain": DOMAIN, + "username": USERNAME, + "password": PASSWORD, + } + }, ) diff --git a/tests/components/nordpool/fixtures/delivery_period_today.json b/tests/components/nordpool/fixtures/delivery_period_today.json index 77d51dc9433..df48c32a9a9 100644 --- a/tests/components/nordpool/fixtures/delivery_period_today.json +++ b/tests/components/nordpool/fixtures/delivery_period_today.json @@ -162,7 +162,7 @@ "deliveryEnd": "2024-11-05T19:00:00Z", "entryPerArea": { "SE3": 1011.77, - "SE4": 1804.46 + "SE4": 0.0 } }, { diff --git a/tests/components/nordpool/snapshots/test_diagnostics.ambr b/tests/components/nordpool/snapshots/test_diagnostics.ambr index 76a3dd96405..d7f7c4041cd 100644 --- a/tests/components/nordpool/snapshots/test_diagnostics.ambr +++ b/tests/components/nordpool/snapshots/test_diagnostics.ambr @@ -519,7 +519,7 @@ 'deliveryStart': '2024-11-05T18:00:00Z', 'entryPerArea': dict({ 'SE3': 1011.77, - 'SE4': 1804.46, + 'SE4': 0.0, }), }), dict({ diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr index 86aa49357c5..be2b04cc520 100644 --- a/tests/components/nordpool/snapshots/test_sensor.ambr +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -1332,7 +1332,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.80446', + 'state': '0.0', }) # --- # name: test_sensor[sensor.nord_pool_se4_daily_average-entry] @@ -1580,9 +1580,9 @@ # name: test_sensor[sensor.nord_pool_se4_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'end': '2024-11-05T03:00:00+00:00', + 'end': '2024-11-05T19:00:00+00:00', 'friendly_name': 'Nord Pool SE4 Lowest price', - 'start': '2024-11-05T02:00:00+00:00', + 'start': '2024-11-05T18:00:00+00:00', 'unit_of_measurement': 'SEK/kWh', }), 'context': , @@ -1590,7 +1590,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.06519', + 'state': '0.0', }) # --- # name: test_sensor[sensor.nord_pool_se4_next_price-entry] diff --git a/tests/components/nordpool/test_sensor.py b/tests/components/nordpool/test_sensor.py index 60be1ee3258..082684a2a02 100644 --- a/tests/components/nordpool/test_sensor.py +++ b/tests/components/nordpool/test_sensor.py @@ -33,6 +33,19 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_current_price_is_0( + hass: HomeAssistant, load_int: ConfigEntry +) -> None: + """Test the Nord Pool sensor working if price is 0.""" + + current_price = hass.states.get("sensor.nord_pool_se4_current_price") + + assert current_price is not None + assert current_price.state == "0.0" # SE4 2024-11-05T18:00:00Z + + @pytest.mark.freeze_time("2024-11-05T23:00:00+00:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_no_next_price(hass: HomeAssistant, load_int: ConfigEntry) -> None: diff --git a/tests/components/nut/test_button.py b/tests/components/nut/test_button.py new file mode 100644 index 00000000000..bbcc521b7f3 --- /dev/null +++ b/tests/components/nut/test_button.py @@ -0,0 +1,102 @@ +"""Test the NUT button platform.""" + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .util import async_init_integration + + +@pytest.mark.parametrize( + "model", + [ + "CP1350C", + "5E650I", + "5E850I", + "CP1500PFCLCD", + "DL650ELCD", + "EATON5P1550", + "blazer_usb", + ], +) +async def test_buttons_ups( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str +) -> None: + """Tests that there are no standard buttons.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + button = hass.states.get("button.ups1_power_cycle_outlet_1") + assert not button + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + ), + ], +) +async def test_buttons_pdu_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Tests that the button entities are correct.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + for num in range(1, 25): + command = f"outlet.{num!s}.load.cycle" + list_commands_return_value[command] = command + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + entity_id = "button.ups1_power_cycle_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{unique_id_base}outlet.1.load.cycle" + + button = hass.states.get(entity_id) + assert button + assert button.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + button = hass.states.get(entity_id) + assert button.state != STATE_UNKNOWN + + button = hass.states.get("button.ups1_power_cycle_outlet_25") + assert not button + + button = hass.states.get("button.ups1_power_cycle_outlet_a25") + assert not button diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index ed9c87f2f90..c0e7f9ffeff 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -6,6 +6,7 @@ from unittest.mock import patch from aionut import NUTError, NUTLoginError from homeassistant import config_entries, setup +from homeassistant.components.nut.config_flow import PASSWORD_NOT_CHANGED from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_ALIAS, @@ -14,14 +15,13 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_RESOURCES, - CONF_SCAN_INTERVAL, CONF_USERNAME, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .util import _get_mock_nutclient +from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry @@ -84,8 +84,8 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_one_ups(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_form_user_one_alias(hass: HomeAssistant) -> None: + """Test we can configure a device with one alias.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -129,8 +129,8 @@ async def test_form_user_one_ups(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_multiple_ups(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_form_user_multiple_aliases(hass: HomeAssistant) -> None: + """Test we can configure device with multiple aliases.""" await setup.async_setup_component(hass, "persistent_notification", {}) config_entry = MockConfigEntry( @@ -195,7 +195,7 @@ async def test_form_user_multiple_ups(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 2 -async def test_form_user_one_ups_with_ignored_entry(hass: HomeAssistant) -> None: +async def test_form_user_one_alias_with_ignored_entry(hass: HomeAssistant) -> None: """Test we can setup a new one when there is an ignored one.""" ignored_entry = MockConfigEntry( domain=DOMAIN, data={}, source=config_entries.SOURCE_IGNORE @@ -245,8 +245,8 @@ async def test_form_user_one_ups_with_ignored_entry(hass: HomeAssistant) -> None assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_no_upses_found(hass: HomeAssistant) -> None: - """Test we abort when the NUT server has not UPSes.""" +async def test_form_no_aliases_found(hass: HomeAssistant) -> None: + """Test we abort when the NUT server has no aliases.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -525,6 +525,104 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" +async def test_abort_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test we abort if unique_id is already setup.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + mock_pynut = _get_mock_nutclient(list_ups={"ups2": "UPS 2"}, list_vars=list_vars) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 2222, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_abort_multiple_aliases_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test we abort on multiple aliases if unique_id is already setup.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + mock_pynut = _get_mock_nutclient( + list_ups={"ups2": "UPS 2", "ups3": "UPS 3"}, list_vars=list_vars + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 2222, + }, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "ups" + assert result2["type"] is FlowResultType.FORM + + await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + async def test_abort_if_already_setup_alias(hass: HomeAssistant) -> None: """Test we abort if component is already setup with same alias.""" config_entry = MockConfigEntry( @@ -575,43 +673,760 @@ async def test_abort_if_already_setup_alias(hass: HomeAssistant) -> None: assert result3["reason"] == "already_configured" -async def test_options_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="abcde12345", - data=VALID_CONFIG, +async def test_reconfigure_one_alias_successful(hass: HomeAssistant) -> None: + """Test reconfigure one alias successful.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, ) - config_entry.add_to_hass(hass) - with patch("homeassistant.components.nut.async_setup_entry", return_value=True): - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await entry.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_SCAN_INTERVAL: 60, - } + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" - with patch("homeassistant.components.nut.async_setup_entry", return_value=True): - result2 = await hass.config_entries.options.async_init(config_entry.entry_id) + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-new-password" + + +async def test_reconfigure_one_alias_nochange(hass: HomeAssistant) -> None: + """Test reconfigure one alias when there is no change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: int(entry.data[CONF_PORT]), + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_password_nochange(hass: HomeAssistant) -> None: + """Test reconfigure one alias when there is no password change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: PASSWORD_NOT_CHANGED, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_already_configured(hass: HomeAssistant) -> None: + """Test reconfigure when config changed to an existing host/port/alias.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: int(entry.data[CONF_PORT]), + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_unique_id_change(hass: HomeAssistant) -> None: + """Test reconfigure when the unique ID is changed.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + }, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_one_alias_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test reconfigure that results in a duplicate unique ID.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + username="test-username", + password="test-password", + list_ups={"ups2": "UPS 2"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups2": "UPS 2"}, + list_vars=list_vars, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "3.3.3.3", + CONF_PORT: 789, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_multiple_aliases_successful(hass: HomeAssistant) -> None: + """Test reconfigure with multiple aliases is successful.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "init" + assert result2["step_id"] == "reconfigure_ups" - result2 = await hass.config_entries.options.async_configure( + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - user_input={CONF_SCAN_INTERVAL: 12}, + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-new-password" + assert entry.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_nochange(hass: HomeAssistant) -> None: + """Test reconfigure with multiple aliases and no change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_SCAN_INTERVAL: 12, - } + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups1"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups1" + + +async def test_reconfigure_multiple_aliases_password_nochange( + hass: HomeAssistant, +) -> None: + """Test reconfigure with multiple aliases when no password change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: PASSWORD_NOT_CHANGED, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_already_configured( + hass: HomeAssistant, +) -> None: + """Test reconfigure multi aliases changed to existing host/port/alias.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars={"battery.voltage": "voltage"}, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + alias="ups2", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups1" + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + assert entry2.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_unique_id_change( + hass: HomeAssistant, +) -> None: + """Test reconfigure with multiple aliases and the unique ID is changed.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_multiple_aliases_duplicate_unique_ids( + hass: HomeAssistant, +) -> None: + """Test reconfigure multi aliases that results in duplicate unique ID.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars=list_vars, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + alias="ups2", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars=list_vars, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "3.3.3.3", + CONF_PORT: 789, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "unique_id_mismatch" diff --git a/tests/components/nut/test_device_action.py b/tests/components/nut/test_device_action.py index 01675f928e3..ea6b7306a5f 100644 --- a/tests/components/nut/test_device_action.py +++ b/tests/components/nut/test_device_action.py @@ -15,6 +15,7 @@ from homeassistant.components.nut import DOMAIN from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS from homeassistant.const import CONF_DEVICE_ID, CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -191,48 +192,39 @@ async def test_action(hass: HomeAssistant, device_registry: dr.DeviceRegistry) - run_command.assert_called_with("someUps", "beeper.disable") -async def test_rund_command_exception( +async def test_run_command_exception( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - caplog: pytest.LogCaptureFixture, ) -> None: - """Test logged error if run command raises exception.""" + """Test if run command raises exception with translation.""" - list_commands_return_value = {"beeper.enable": None} - error_message = "Something wrong happened" - run_command = AsyncMock(side_effect=NUTError(error_message)) + command_name = "beeper.enable" + nut_error_message = "Something wrong happened" + run_command = AsyncMock(side_effect=NUTError(nut_error_message)) await async_init_integration( hass, list_vars={"ups.status": "OL"}, - list_commands_return_value=list_commands_return_value, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value={command_name: None}, run_command=run_command, ) device_entry = next(device for device in device_registry.devices.values()) - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: [ - { - "trigger": { - "platform": "event", - "event_type": "test_some_event", - }, - "action": { - "domain": DOMAIN, - "device_id": device_entry.id, - "type": "beeper_enable", - }, - }, - ] - }, + platform = await device_automation.async_get_device_automation_platform( + hass, DOMAIN, DeviceAutomationType.ACTION ) - hass.bus.async_fire("test_some_event") - await hass.async_block_till_done() - - assert error_message in caplog.text + error_message = f"Error running command {command_name}, {nut_error_message}" + with pytest.raises(HomeAssistantError, match=error_message): + await platform.async_call_action_from_config( + hass, + { + CONF_TYPE: command_name, + CONF_DEVICE_ID: device_entry.id, + }, + {}, + None, + ) async def test_action_exception_invalid_device(hass: HomeAssistant) -> None: @@ -248,10 +240,12 @@ async def test_action_exception_invalid_device(hass: HomeAssistant) -> None: hass, DOMAIN, DeviceAutomationType.ACTION ) - with pytest.raises(InvalidDeviceAutomationConfig): + device_id = "invalid_device_id" + error_message = f"Unable to find a NUT device with ID {device_id}" + with pytest.raises(InvalidDeviceAutomationConfig, match=error_message): await platform.async_call_action_from_config( hass, - {CONF_TYPE: "beeper.enable", CONF_DEVICE_ID: "invalid_device_id"}, + {CONF_TYPE: "beeper.enable", CONF_DEVICE_ID: device_id}, {}, None, ) diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index 0585696cef2..b3cf23bddcc 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -4,6 +4,7 @@ from copy import deepcopy from unittest.mock import patch from aionut import NUTError, NUTLoginError +import pytest from homeassistant.components.nut.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -11,6 +12,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_USERNAME, STATE_UNAVAILABLE, ) @@ -22,6 +24,32 @@ from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry +async def test_config_entry_migrations(hass: HomeAssistant) -> None: + """Test that config entries were migrated.""" + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 123, + }, + options={CONF_SCAN_INTERVAL: 30}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + assert CONF_SCAN_INTERVAL not in entry.options + + async def test_async_setup_entry(hass: HomeAssistant) -> None: """Test a successful setup entry.""" entry = MockConfigEntry( @@ -56,7 +84,10 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: assert not hass.data.get(DOMAIN) -async def test_config_not_ready(hass: HomeAssistant) -> None: +async def test_config_not_ready( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: """Test for setup failure if connection to broker is missing.""" entry = MockConfigEntry( domain=DOMAIN, @@ -64,6 +95,8 @@ async def test_config_not_ready(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) + nut_error_message = "Something wrong happened" + error_message = f"Error fetching UPS state: {nut_error_message}" with ( patch( "homeassistant.components.nut.AIONUTClient.list_ups", @@ -71,15 +104,20 @@ async def test_config_not_ready(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.nut.AIONUTClient.list_vars", - side_effect=NUTError, + side_effect=NUTError(nut_error_message), ), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY + assert error_message in caplog.text -async def test_auth_fails(hass: HomeAssistant) -> None: + +async def test_auth_fails( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: """Test for setup failure if auth has changed.""" entry = MockConfigEntry( domain=DOMAIN, @@ -87,6 +125,8 @@ async def test_auth_fails(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) + nut_error_message = "Something wrong happened" + error_message = f"Device authentication error: {nut_error_message}" with ( patch( "homeassistant.components.nut.AIONUTClient.list_ups", @@ -94,13 +134,15 @@ async def test_auth_fails(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.nut.AIONUTClient.list_vars", - side_effect=NUTLoginError, + side_effect=NUTLoginError(nut_error_message), ), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_ERROR + assert error_message in caplog.text + flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["context"]["source"] == "reauth" diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index 6483d581070..89f06c934f8 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -7,15 +7,20 @@ import pytest from homeassistant.components.nut.const import DOMAIN from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_PORT, CONF_RESOURCES, PERCENTAGE, STATE_UNKNOWN, + Platform, + UnitOfElectricCurrent, UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, translation from .util import ( _get_mock_nutclient, @@ -52,9 +57,9 @@ async def test_ups_devices( assert state.state == "100" expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, + ATTR_DEVICE_CLASS: "battery", + ATTR_FRIENDLY_NAME: "Ups1 Battery charge", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -87,9 +92,9 @@ async def test_ups_devices_with_unique_ids( assert state.state == "100" expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, + ATTR_DEVICE_CLASS: "battery", + ATTR_FRIENDLY_NAME: "Ups1 Battery charge", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -103,7 +108,7 @@ async def test_ups_devices_with_unique_ids( [ ( "EATON-EPDU-G3", - "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", ), ], ) @@ -115,44 +120,46 @@ async def test_pdu_devices_with_unique_ids( ) -> None: """Test creation of device sensors with unique ids.""" - await _test_sensor_and_attributes( + await async_init_integration(hass, model) + + _test_sensor_and_attributes( hass, entity_registry, model, - unique_id=f"{unique_id_base}input.voltage", + unique_id=f"{unique_id_base}_input.voltage", device_id="sensor.ups1_input_voltage", state_value="122.91", expected_attributes={ - "device_class": SensorDeviceClass.VOLTAGE, + ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, "state_class": SensorStateClass.MEASUREMENT, - "friendly_name": "Ups1 Input voltage", - "unit_of_measurement": UnitOfElectricPotential.VOLT, + ATTR_FRIENDLY_NAME: "Ups1 Input voltage", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, }, ) - await _test_sensor_and_attributes( + _test_sensor_and_attributes( hass, entity_registry, model, - unique_id=f"{unique_id_base}ambient.humidity.status", + unique_id=f"{unique_id_base}_ambient.humidity.status", device_id="sensor.ups1_ambient_humidity_status", state_value="good", expected_attributes={ - "device_class": SensorDeviceClass.ENUM, - "friendly_name": "Ups1 Ambient humidity status", + ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, + ATTR_FRIENDLY_NAME: "Ups1 Ambient humidity status", }, ) - await _test_sensor_and_attributes( + _test_sensor_and_attributes( hass, entity_registry, model, - unique_id=f"{unique_id_base}ambient.temperature.status", + unique_id=f"{unique_id_base}_ambient.temperature.status", device_id="sensor.ups1_ambient_temperature_status", state_value="good", expected_attributes={ - "device_class": SensorDeviceClass.ENUM, - "friendly_name": "Ups1 Ambient temperature status", + ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, + ATTR_FRIENDLY_NAME: "Ups1 Ambient temperature status", }, ) @@ -243,12 +250,42 @@ async def test_stale_options( assert state.state == "10" +async def test_state_ambient_translation(hass: HomeAssistant) -> None: + """Test translation of ambient state sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, + ) + entry.add_to_hass(hass) + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, list_vars={"ambient.humidity.status": "good"} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + key = "ambient_humidity_status" + state = hass.states.get(f"sensor.ups1_{key}") + assert state.state == "good" + + result = translation.async_translate_state( + hass, state.state, Platform.SENSOR, DOMAIN, key, None + ) + + assert result == "Good" + + @pytest.mark.parametrize( ("model", "unique_id_base"), [ ( "EATON-EPDU-G3-AMBIENT-NOT-PRESENT", - "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", ), ], ) @@ -273,3 +310,57 @@ async def test_pdu_devices_ambient_not_present( entry = entity_registry.async_get("sensor.ups1_ambient_temperature_status") assert not entry + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", + ), + ], +) +async def test_pdu_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Test for dynamically created outlet sensors.""" + + await async_init_integration(hass, model) + + _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}_outlet.1.current", + device_id="sensor.ups1_outlet_a1_current", + state_value="0", + expected_attributes={ + ATTR_DEVICE_CLASS: SensorDeviceClass.CURRENT, + ATTR_FRIENDLY_NAME: "Ups1 Outlet A1 current", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricCurrent.AMPERE, + }, + ) + + _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}_outlet.24.current", + device_id="sensor.ups1_outlet_a24_current", + state_value="0.19", + expected_attributes={ + ATTR_DEVICE_CLASS: SensorDeviceClass.CURRENT, + ATTR_FRIENDLY_NAME: "Ups1 Outlet A24 current", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricCurrent.AMPERE, + }, + ) + + entry = entity_registry.async_get("sensor.ups1_outlet_25_current") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_outlet_a25_current") + assert not entry diff --git a/tests/components/nut/test_switch.py b/tests/components/nut/test_switch.py new file mode 100644 index 00000000000..f2de5eeb5e6 --- /dev/null +++ b/tests/components/nut/test_switch.py @@ -0,0 +1,159 @@ +"""Test the NUT switch platform.""" + +import json +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .util import async_init_integration + +from tests.common import load_fixture + + +@pytest.mark.parametrize( + "model", + [ + "CP1350C", + "5E650I", + "5E850I", + "CP1500PFCLCD", + "DL650ELCD", + "EATON5P1550", + "blazer_usb", + ], +) +async def test_switch_ups( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str +) -> None: + """Tests that there are no standard switches.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + switch = hass.states.get("switch.ups1_power_outlet_1") + assert not switch + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", + ), + ], +) +async def test_switch_pdu_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Tests that the switch entities are correct.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + for num in range(1, 25): + command = f"outlet.{num!s}.load.on" + list_commands_return_value[command] = command + command = f"outlet.{num!s}.load.off" + list_commands_return_value[command] = command + + ups_fixture = f"nut/{model}.json" + list_vars = json.loads(load_fixture(ups_fixture)) + + run_command = AsyncMock() + + await async_init_integration( + hass, + model, + list_vars=list_vars, + list_commands_return_value=list_commands_return_value, + run_command=run_command, + ) + + entity_id = "switch.ups1_power_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{unique_id_base}_outlet.1.load.poweronoff" + + switch = hass.states.get(entity_id) + assert switch + assert switch.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + run_command.assert_called_with("ups1", "outlet.1.load.off") + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + run_command.assert_called_with("ups1", "outlet.1.load.on") + + switch = hass.states.get("switch.ups1_power_outlet_25") + assert not switch + + switch = hass.states.get("switch.ups1_power_outlet_a25") + assert not switch + + +async def test_switch_pdu_dynamic_outlets_state_unknown( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test switch entity with missing status is reported as unknown.""" + + config_entry = await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars={ + "outlet.count": "1", + "outlet.1.switchable": "yes", + "outlet.1.name": "A1", + }, + list_commands_return_value={ + "outlet.1.load.on": None, + "outlet.1.load.off": None, + }, + ) + + entity_id = "switch.ups1_power_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{config_entry.entry_id}_outlet.1.load.poweronoff" + + switch = hass.states.get(entity_id) + assert switch + assert switch.state == STATE_UNKNOWN diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index bd82ffdd6b4..889fdc327af 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -1,10 +1,17 @@ """Tests for the nut integration.""" import json +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.nut.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_ALIAS, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -35,8 +42,11 @@ def _get_mock_nutclient( async def async_init_integration( hass: HomeAssistant, ups_fixture: str | None = None, + host: str = "mock", + port: str = "mock", username: str = "mock", password: str = "mock", + alias: str | None = None, list_ups: dict[str, str] | None = None, list_vars: dict[str, str] | None = None, list_commands_return_value: dict[str, str] | None = None, @@ -65,15 +75,24 @@ async def async_init_integration( "homeassistant.components.nut.AIONUTClient", return_value=mock_pynut, ): + extra_config_entry_data: dict[str, Any] = {} + + if alias is not None: + extra_config_entry_data = { + CONF_ALIAS: alias, + } + entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "mock", + CONF_HOST: host, CONF_PASSWORD: password, - CONF_PORT: "mock", + CONF_PORT: port, CONF_USERNAME: username, - }, + } + | extra_config_entry_data, ) + entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -82,7 +101,7 @@ async def async_init_integration( return entry -async def _test_sensor_and_attributes( +def _test_sensor_and_attributes( hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str, @@ -91,9 +110,8 @@ async def _test_sensor_and_attributes( state_value: str, expected_attributes: dict, ) -> None: - """Test creation of device sensors with unique ids.""" + """Test all of the sensor entry attributes.""" - await async_init_integration(hass, model) entry = entity_registry.async_get(device_id) assert entry assert entry.unique_id == unique_id diff --git a/tests/components/ohme/test_sensor.py b/tests/components/ohme/test_sensor.py index 21f9f06f963..8fc9edddcf9 100644 --- a/tests/components/ohme/test_sensor.py +++ b/tests/components/ohme/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory from ohme import ApiException +import pytest from syrupy import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform @@ -16,6 +17,7 @@ from . import setup_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/ohme/test_services.py b/tests/components/ohme/test_services.py index 2513635c1c2..c228ddcd9a7 100644 --- a/tests/components/ohme/test_services.py +++ b/tests/components/ohme/test_services.py @@ -1,7 +1,9 @@ """Tests for services.""" +from datetime import datetime from unittest.mock import AsyncMock, MagicMock +from ohme import ChargeSlot import pytest from syrupy.assertion import SnapshotAssertion @@ -30,11 +32,11 @@ async def test_list_charge_slots( await setup_integration(hass, mock_config_entry) mock_client.slots = [ - { - "start": "2024-12-30T04:00:00+00:00", - "end": "2024-12-30T04:30:39+00:00", - "energy": 2.042, - } + ChargeSlot( + datetime.fromisoformat("2024-12-30T04:00:00+00:00"), + datetime.fromisoformat("2024-12-30T04:30:39+00:00"), + 2.042, + ) ] assert snapshot == await hass.services.async_call( diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index d0a6afa50b5..9c5e93e49fe 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -6,12 +6,16 @@ from http import HTTPStatus from io import StringIO import os from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import ANY, DEFAULT, AsyncMock, MagicMock, Mock, patch +from hass_nabucasa.auth import CognitoAuth +from hass_nabucasa.const import STATE_CONNECTED +from hass_nabucasa.iot import CloudIoT import pytest from syrupy import SnapshotAssertion from homeassistant.components import backup, onboarding +from homeassistant.components.cloud import DOMAIN as CLOUD_DOMAIN, CloudClient from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -32,11 +36,9 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -def auth_active(hass: HomeAssistant) -> None: +async def auth_active(hass: HomeAssistant) -> None: """Ensure auth is always active.""" - hass.loop.run_until_complete( - register_auth_provider(hass, {"type": "homeassistant"}) - ) + await register_auth_provider(hass, {"type": "homeassistant"}) @pytest.fixture(name="rpi") @@ -1067,3 +1069,139 @@ async def test_onboarding_backup_upload( assert resp.status == 201 assert await resp.json() == {"backup_id": "abc123"} mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) + + +@pytest.fixture(name="cloud") +async def cloud_fixture() -> AsyncGenerator[MagicMock]: + """Mock the cloud object. + + See the real hass_nabucasa.Cloud class for how to configure the mock. + """ + with patch( + "homeassistant.components.cloud.Cloud", autospec=True + ) as mock_cloud_class: + mock_cloud = mock_cloud_class.return_value + + mock_cloud.auth = MagicMock(spec=CognitoAuth) + mock_cloud.iot = MagicMock( + spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED + ) + + def set_up_mock_cloud( + cloud_client: CloudClient, mode: str, **kwargs: Any + ) -> DEFAULT: + """Set up mock cloud with a mock constructor.""" + + # Attributes set in the constructor with parameters. + mock_cloud.client = cloud_client + + return DEFAULT + + mock_cloud_class.side_effect = set_up_mock_cloud + + # Attributes that we mock with default values. + mock_cloud.id_token = None + mock_cloud.is_logged_in = False + + yield mock_cloud + + +@pytest.fixture(name="setup_cloud") +async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: + """Fixture that sets up cloud.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, CLOUD_DOMAIN, {}) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("setup_cloud") +async def test_onboarding_cloud_forgot_password( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test cloud forgot password.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + mock_cognito = cloud.auth + + req = await client.post( + "/api/onboarding/cloud/forgot_password", json={"email": "hello@bla.com"} + ) + + assert req.status == HTTPStatus.OK + assert mock_cognito.async_forgot_password.call_count == 1 + + +@pytest.mark.usefixtures("setup_cloud") +async def test_onboarding_cloud_login( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post( + "/api/onboarding/cloud/login", + json={"email": "my_username", "password": "my_password"}, + ) + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"cloud_pipeline": None, "success": True} + assert cloud.login.call_count == 1 + + +@pytest.mark.usefixtures("setup_cloud") +async def test_onboarding_cloud_logout( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post("/api/onboarding/cloud/logout") + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"message": "ok"} + assert cloud.logout.call_count == 1 + + +@pytest.mark.usefixtures("setup_cloud") +async def test_onboarding_cloud_status( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.get("/api/onboarding/cloud/status") + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"logged_in": False} diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 28186503ead..92a4a34e8fb 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -1,12 +1,10 @@ """Test Onkyo config flow.""" -from typing import Any from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components.onkyo import InputSource from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, @@ -536,89 +534,6 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None: assert config_entry.unique_id == old_unique_id -@pytest.mark.parametrize( - ("user_input", "exception", "error"), - [ - ( - # No host, and thus no host reachable - { - CONF_HOST: None, - "receiver_max_volume": 100, - "max_volume": 100, - "sources": {}, - }, - None, - "cannot_connect", - ), - ( - # No host, and connection exception - { - CONF_HOST: None, - "receiver_max_volume": 100, - "max_volume": 100, - "sources": {}, - }, - Exception(), - "cannot_connect", - ), - ], -) -async def test_import_fail( - hass: HomeAssistant, - user_input: dict[str, Any], - exception: Exception, - error: str, -) -> None: - """Test import flow failed.""" - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=user_input - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == error - - -async def test_import_success( - hass: HomeAssistant, -) -> None: - """Test import flow succeeded.""" - info = create_receiver_info(1) - - user_input = { - CONF_HOST: info.host, - "receiver_max_volume": 80, - "max_volume": 110, - "sources": { - InputSource("00"): "Auxiliary", - InputSource("01"): "Video", - }, - "info": info, - } - - import_result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=user_input - ) - await hass.async_block_till_done() - - assert import_result["type"] is FlowResultType.CREATE_ENTRY - assert import_result["data"] == {"host": "host 1"} - assert import_result["options"] == { - "volume_resolution": 80, - "max_volume": 100, - "input_sources": { - "00": "Auxiliary", - "01": "Video", - }, - "listening_modes": {}, - } - - @pytest.mark.parametrize( "ignore_missing_translations", [ diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 90a08471f39..17a5aad6478 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -1,9 +1,10 @@ """Test the OpenAI Conversation config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from httpx import Response +import httpx from openai import APIConnectionError, AuthenticationError, BadRequestError +from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText import pytest from homeassistant import config_entries @@ -16,6 +17,13 @@ from homeassistant.components.openai_conversation.const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, @@ -117,13 +125,17 @@ async def test_options_unsupported_model( (APIConnectionError(request=None), "cannot_connect"), ( AuthenticationError( - response=Response(status_code=None, request=""), body=None, message=None + response=httpx.Response(status_code=None, request=""), + body=None, + message=None, ), "invalid_auth", ), ( BadRequestError( - response=Response(status_code=None, request=""), body=None, message=None + response=httpx.Response(status_code=None, request=""), + body=None, + message=None, ), "unknown", ), @@ -172,6 +184,9 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: False, }, ), ( @@ -183,6 +198,9 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: False, }, { CONF_RECOMMENDED: True, @@ -225,3 +243,105 @@ async def test_options_switching( await hass.async_block_till_done() assert options["type"] is FlowResultType.CREATE_ENTRY assert options["data"] == expected_options + + +async def test_options_web_search_user_location( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test fetching user location.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + hass.config.country = "US" + hass.config.time_zone = "America/Los_Angeles" + hass.states.async_set( + "zone.home", "0", {"latitude": 37.7749, "longitude": -122.4194} + ) + with patch( + "openai.resources.responses.AsyncResponses.create", + new_callable=AsyncMock, + ) as mock_create: + mock_create.return_value = Response( + object="response", + id="resp_A", + created_at=1700000000, + model="gpt-4o-mini", + parallel_tool_calls=True, + tool_choice="auto", + tools=[], + output=[ + ResponseOutputMessage( + type="message", + id="msg_A", + content=[ + ResponseOutputText( + type="output_text", + text='{"city": "San Francisco", "region": "California"}', + annotations=[], + ) + ], + role="assistant", + status="completed", + ) + ], + ) + + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: True, + }, + ) + await hass.async_block_till_done() + assert ( + mock_create.call_args.kwargs["input"][0]["content"] == "Where are the following" + " coordinates located: (37.7749, -122.4194)?" + ) + assert options["type"] is FlowResultType.CREATE_ENTRY + assert options["data"] == { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + } + + +async def test_options_web_search_unsupported_model( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the options form giving error about web search not being available.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + result = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_CHAT_MODEL: "o1-pro", + CONF_LLM_HASS_API: "assist", + CONF_WEB_SEARCH: True, + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"web_search": "web_search_not_supported"} diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index bfcacefb044..d6f09e0f30e 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -12,9 +12,14 @@ from openai.types.responses import ( ResponseContentPartAddedEvent, ResponseContentPartDoneEvent, ResponseCreatedEvent, + ResponseError, + ResponseErrorEvent, + ResponseFailedEvent, ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionCallArgumentsDoneEvent, ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseIncompleteEvent, ResponseInProgressEvent, ResponseOutputItemAddedEvent, ResponseOutputItemDoneEvent, @@ -25,12 +30,25 @@ from openai.types.responses import ( ResponseTextConfig, ResponseTextDeltaEvent, ResponseTextDoneEvent, + ResponseWebSearchCallCompletedEvent, + ResponseWebSearchCallInProgressEvent, + ResponseWebSearchCallSearchingEvent, ) +from openai.types.responses.response import IncompleteDetails import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.openai_conversation.const import ( + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, +) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import intent @@ -83,17 +101,40 @@ def mock_create_stream() -> Generator[AsyncMock]: response=response, type="response.in_progress", ) + response.status = "completed" for value in events: if isinstance(value, ResponseOutputItemDoneEvent): response.output.append(value.item) + elif isinstance(value, IncompleteDetails): + response.status = "incomplete" + response.incomplete_details = value + break + if isinstance(value, ResponseError): + response.status = "failed" + response.error = value + break + yield value - response.status = "completed" - yield ResponseCompletedEvent( - response=response, - type="response.completed", - ) + if isinstance(value, ResponseErrorEvent): + return + + if response.status == "incomplete": + yield ResponseIncompleteEvent( + response=response, + type="response.incomplete", + ) + elif response.status == "failed": + yield ResponseFailedEvent( + response=response, + type="response.failed", + ) + else: + yield ResponseCompletedEvent( + response=response, + type="response.completed", + ) with patch( "openai.resources.responses.AsyncResponses.create", @@ -175,6 +216,121 @@ async def test_error_handling( assert result.response.speech["plain"]["speech"] == message, result.response.speech +@pytest.mark.parametrize( + ("reason", "message"), + [ + ( + "max_output_tokens", + "max output tokens reached", + ), + ( + "content_filter", + "content filter triggered", + ), + ( + None, + "unknown reason", + ), + ], +) +async def test_incomplete_response( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + reason: str, + message: str, +) -> None: + """Test handling early model stop.""" + # Incomplete details received after some content is generated + mock_create_stream.return_value = [ + ( + # Start message + *create_message_item( + id="msg_A", + text=["Once upon", " a time, ", "there was "], + output_index=0, + ), + # Length limit or content filter + IncompleteDetails(reason=reason), + ) + ] + + result = await conversation.async_converse( + hass, + "Please tell me a big story", + "mock-conversation-id", + Context(), + agent_id="conversation.openai", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert ( + result.response.speech["plain"]["speech"] + == f"OpenAI response incomplete: {message}" + ), result.response.speech + + # Incomplete details received before any content is generated + mock_create_stream.return_value = [ + ( + # Start generating response + *create_reasoning_item(id="rs_A", output_index=0), + # Length limit or content filter + IncompleteDetails(reason=reason), + ) + ] + + result = await conversation.async_converse( + hass, + "please tell me a big story", + "mock-conversation-id", + Context(), + agent_id="conversation.openai", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert ( + result.response.speech["plain"]["speech"] + == f"OpenAI response incomplete: {message}" + ), result.response.speech + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + ResponseError(code="rate_limit_exceeded", message="Rate limit exceeded"), + "OpenAI response failed: Rate limit exceeded", + ), + ( + ResponseErrorEvent(type="error", message="Some error"), + "OpenAI response error: Some error", + ), + ], +) +async def test_failed_response( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + error: ResponseError | ResponseErrorEvent, + message: str, +) -> None: + """Test handling failed and error responses.""" + mock_create_stream.return_value = [(error,)] + + result = await conversation.async_converse( + hass, + "next natural number please", + "mock-conversation-id", + Context(), + agent_id="conversation.openai", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.speech["plain"]["speech"] == message, result.response.speech + + async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -346,6 +502,41 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven ] +def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a web search call item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionWebSearch( + id=id, status="in_progress", type="web_search_call" + ), + output_index=output_index, + type="response.output_item.added", + ), + ResponseWebSearchCallInProgressEvent( + item_id=id, + output_index=output_index, + type="response.web_search_call.in_progress", + ), + ResponseWebSearchCallSearchingEvent( + item_id=id, + output_index=output_index, + type="response.web_search_call.searching", + ), + ResponseWebSearchCallCompletedEvent( + item_id=id, + output_index=output_index, + type="response.web_search_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ResponseFunctionWebSearch( + id=id, status="completed", type="web_search_call" + ), + output_index=output_index, + type="response.output_item.done", + ), + ] + + async def test_function_call( hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, @@ -436,7 +627,6 @@ async def test_function_call_invalid( mock_config_entry_with_assist: MockConfigEntry, mock_init_component, mock_create_stream: AsyncMock, - mock_chat_log: MockChatLog, # noqa: F811 description: str, messages: tuple[ResponseStreamEvent], ) -> None: @@ -488,3 +678,60 @@ async def test_assist_api_tools_conversion( tools = mock_create_stream.mock_calls[0][2]["tools"] assert tools + + +async def test_web_search( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test web_search_tool.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + **mock_config_entry.options, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + + message = "Home Assistant now supports ChatGPT Search in Assist" + mock_create_stream.return_value = [ + # Initial conversation + ( + *create_web_search_item(id="ws_A", output_index=0), + *create_message_item(id="msg_A", text=message, output_index=1), + ) + ] + + result = await conversation.async_converse( + hass, + "What's on the latest news?", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai", + ) + + assert mock_create_stream.mock_calls[0][2]["tools"] == [ + { + "type": "web_search_preview", + "search_context_size": "low", + "user_location": { + "type": "approximate", + "city": "San Francisco", + "region": "California", + "country": "US", + "timezone": "America/Los_Angeles", + }, + } + ] + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == message, result.response.speech diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 5aef68841ee..c4d5605de03 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -262,6 +262,27 @@ async def test_init_error( }, 0, ), + ( + {"prompt": "Picture of a dog", "filenames": ["/a/b/c.pdf"]}, + { + "input": [ + { + "content": [ + { + "type": "input_text", + "text": "Picture of a dog", + }, + { + "type": "input_file", + "file_data": "data:application/pdf;base64,BASE64IMAGE1", + "filename": "/a/b/c.pdf", + }, + ], + }, + ], + }, + 1, + ), ( {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, { @@ -415,8 +436,8 @@ async def test_generate_content_service( [True, False], ), ( - {"prompt": "Not a picture of a dog", "filenames": ["/a/b/c.pdf"]}, - "Only images are supported by the OpenAI API,`/a/b/c.pdf` is not an image file", + {"prompt": "Not a picture of a dog", "filenames": ["/a/b/c.mov"]}, + "Only images and PDF are supported by the OpenAI API,`/a/b/c.mov` is not an image file or PDF", 1, [True], [True], diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 93f40d0ae3d..a659244e0a0 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -291,13 +291,13 @@ BAD_JSON_SUFFIX = "** and it ends here ^^" @pytest.fixture -def setup_comp( +async def setup_comp( hass: HomeAssistant, mock_device_tracker_conf: list[Device], mqtt_mock: MqttMockHAClient, ) -> None: """Initialize components.""" - hass.loop.run_until_complete(async_setup_component(hass, "device_tracker", {})) + await async_setup_component(hass, "device_tracker", {}) hass.states.async_set("zone.inner", "zoning", INNER_ZONE) @@ -320,7 +320,7 @@ async def setup_owntracks( @pytest.fixture -def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: +async def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: """Set up the mocked context.""" orig_context = owntracks.OwnTracksContext context = None @@ -331,16 +331,14 @@ def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: context = orig_context(*args) return context - hass.loop.run_until_complete( - setup_owntracks( - hass, - { - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_WAYPOINT_WHITELIST: ["jon", "greg"], - }, - store_context, - ) + await setup_owntracks( + hass, + { + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_WAYPOINT_WHITELIST: ["jon", "greg"], + }, + store_context, ) def get_context(): diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index 5ef0efb0ab9..266a66b2760 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -43,7 +43,7 @@ def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: @pytest.fixture -def mock_client( +async def mock_client( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> TestClient: """Start the Home Assistant HTTP component.""" @@ -54,9 +54,9 @@ def mock_client( MockConfigEntry( domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"} ).add_to_hass(hass) - hass.loop.run_until_complete(async_setup_component(hass, "owntracks", {})) + await async_setup_component(hass, "owntracks", {}) - return hass.loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() async def test_handle_valid_message(mock_client) -> None: diff --git a/tests/components/person/conftest.py b/tests/components/person/conftest.py index a6dc95ccc9e..2b1724f0c48 100644 --- a/tests/components/person/conftest.py +++ b/tests/components/person/conftest.py @@ -31,7 +31,7 @@ def storage_collection(hass: HomeAssistant) -> person.PersonStorageCollection: @pytest.fixture -def storage_setup( +async def storage_setup( hass: HomeAssistant, hass_storage: dict[str, Any], hass_admin_user: MockUser ) -> None: """Storage setup.""" @@ -49,4 +49,4 @@ def storage_setup( ] }, } - assert hass.loop.run_until_complete(async_setup_component(hass, DOMAIN, {})) + assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/pglab/test_common.py b/tests/components/pglab/test_common.py new file mode 100644 index 00000000000..0ff3271d5d6 --- /dev/null +++ b/tests/components/pglab/test_common.py @@ -0,0 +1,50 @@ +"""Common code for PG LAB Electronics tests.""" + +import json + +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message + + +def get_device_discovery_payload( + number_of_shutters: int, + number_of_boards: int, + device_name: str = "test", +) -> dict[str, any]: + """Return the device discovery payload.""" + + # be sure the number of shutters and boards are in the correct range + assert 0 <= number_of_boards <= 8 + assert 0 <= number_of_shutters <= (number_of_boards * 4) + + # define the number of E-RELAY boards connected to E-BOARD + boards = "1" * number_of_boards + "0" * (8 - number_of_boards) + + return { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": device_name, + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-BOARD", + "id": "E-BOARD-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": number_of_shutters, "boards": boards}, + } + + +async def send_discovery_message( + hass: HomeAssistant, + payload: dict[str, any] | None, +) -> None: + """Send the discovery message to make E-BOARD device discoverable.""" + + topic = "pglab/discovery/E-BOARD-DD53AC85/config" + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload if payload is not None else ""), + ) + await hass.async_block_till_done() diff --git a/tests/components/pglab/test_cover.py b/tests/components/pglab/test_cover.py new file mode 100644 index 00000000000..aa92e2da433 --- /dev/null +++ b/tests/components/pglab/test_cover.py @@ -0,0 +1,161 @@ +"""The tests for the PG LAB Electronics cover.""" + +from homeassistant.components import cover +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from .test_common import get_device_discovery_payload, send_discovery_message + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + +COVER_FEATURES = ( + cover.CoverEntityFeature.OPEN + | cover.CoverEntityFeature.CLOSE + | cover.CoverEntityFeature.STOP +) + + +async def call_service(hass: HomeAssistant, entity_id, service, **kwargs): + """Call a service.""" + await hass.services.async_call( + COVER_DOMAIN, + service, + {"entity_id": entity_id, **kwargs}, + blocking=True, + ) + + +async def test_cover_features( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Test cover features.""" + + payload = get_device_discovery_payload( + number_of_shutters=4, + number_of_boards=1, + ) + + await send_discovery_message(hass, payload) + + assert len(hass.states.async_all("cover")) == 4 + + for i in range(4): + cover = hass.states.get(f"cover.test_shutter_{i}") + assert cover + assert cover.attributes["supported_features"] == COVER_FEATURES + + +async def test_cover_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Check if covers are properly created.""" + + payload = get_device_discovery_payload( + number_of_shutters=6, + number_of_boards=2, + ) + + await send_discovery_message(hass, payload) + + # We are creating 6 covers using two E-RELAY devices connected to E-BOARD. + # Now we are going to check if all covers are created and their state is unknown. + for i in range(5): + cover = hass.states.get(f"cover.test_shutter_{i}") + assert cover.state == STATE_UNKNOWN + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + + # The cover with id 7 should not be created. + cover = hass.states.get("cover.test_shutter_7") + assert not cover + + +async def test_cover_change_state_via_mqtt( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Test state update via MQTT.""" + payload = get_device_discovery_payload( + number_of_shutters=2, + number_of_boards=1, + ) + + await send_discovery_message(hass, payload) + + # Check initial state is unknown + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_UNKNOWN + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + + # Simulate the device responds sending mqtt messages and check if the cover state + # change appropriately. + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "OPEN") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + assert cover.state == STATE_OPEN + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "OPENING") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_OPENING + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "CLOSING") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_CLOSING + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "CLOSED") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_CLOSED + + +async def test_cover_mqtt_state_by_calling_service( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Calling service to OPEN/CLOSE cover and check mqtt state.""" + + payload = get_device_discovery_payload( + number_of_shutters=2, + number_of_boards=1, + ) + + await send_discovery_message(hass, payload) + + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_UNKNOWN + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + + # Call HA covers services and verify that the MQTT messages are sent correctly + + await call_service(hass, "cover.test_shutter_0", SERVICE_OPEN_COVER) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/shutter/0/set", "OPEN", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + await call_service(hass, "cover.test_shutter_0", SERVICE_STOP_COVER) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/shutter/0/set", "STOP", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + await call_service(hass, "cover.test_shutter_0", SERVICE_CLOSE_COVER) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/shutter/0/set", "CLOSE", 0, False + ) + mqtt_mock.async_publish.reset_mock() diff --git a/tests/components/pglab/test_discovery.py b/tests/components/pglab/test_discovery.py index 65716236277..df897264163 100644 --- a/tests/components/pglab/test_discovery.py +++ b/tests/components/pglab/test_discovery.py @@ -1,13 +1,12 @@ """The tests for the PG LAB Electronics discovery device.""" -import json - from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from tests.common import async_fire_mqtt_message +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.typing import MqttMockHAClient @@ -19,25 +18,13 @@ async def test_device_discover( setup_pglab, ) -> None: """Test setting up a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device and registry entries are created device_entry = device_reg.async_get_device( @@ -60,25 +47,12 @@ async def test_device_update( snapshot: SnapshotAssertion, ) -> None: """Test update a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -90,12 +64,7 @@ async def test_device_update( payload["fw"] = "1.0.1" payload["hw"] = "1.0.8" - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), - ) - await hass.async_block_till_done() + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -114,25 +83,12 @@ async def test_device_remove( setup_pglab, ) -> None: """Test remove a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -140,12 +96,7 @@ async def test_device_remove( ) assert device_entry is not None - async_fire_mqtt_message( - hass, - topic, - "", - ) - await hass.async_block_till_done() + await send_discovery_message(hass, None) # Verify device entry is removed device_entry = device_reg.async_get_device( diff --git a/tests/components/pglab/test_sensor.py b/tests/components/pglab/test_sensor.py index ff20d1452a4..75932dd036c 100644 --- a/tests/components/pglab/test_sensor.py +++ b/tests/components/pglab/test_sensor.py @@ -8,34 +8,12 @@ from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient -async def send_discovery_message(hass: HomeAssistant) -> None: - """Send mqtt discovery message.""" - - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "00000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), - ) - await hass.async_block_till_done() - - @freeze_time("2024-02-26 01:21:34") @pytest.mark.parametrize( "sensor_suffix", @@ -55,7 +33,12 @@ async def test_sensors( """Check if sensors are properly created and updated.""" # send the discovery message to make E-BOARD device discoverable - await send_discovery_message(hass) + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=0, + ) + + await send_discovery_message(hass, payload) # check initial sensors state state = hass.states.get(f"sensor.test_{sensor_suffix}") diff --git a/tests/components/pglab/test_switch.py b/tests/components/pglab/test_switch.py index fef445f80f3..0f1a2e4bb04 100644 --- a/tests/components/pglab/test_switch.py +++ b/tests/components/pglab/test_switch.py @@ -1,7 +1,6 @@ """The tests for the PG LAB Electronics switch.""" from datetime import timedelta -import json from homeassistant import config_entries from homeassistant.components.switch import ( @@ -20,6 +19,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.common import async_fire_mqtt_message, async_fire_time_changed from tests.typing import MqttMockHAClient @@ -38,25 +39,13 @@ async def test_available_relay( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Check if relay are properly created when two E-Relay boards are connected.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) for i in range(16): state = hass.states.get(f"switch.test_relay_{i}") @@ -68,25 +57,13 @@ async def test_change_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Test state update via MQTT.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Simulate response from the device state = hass.states.get("switch.test_relay_0") @@ -123,25 +100,13 @@ async def test_mqtt_state_by_calling_service( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Calling service to turn ON/OFF relay and check mqtt state.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Turn relay ON await call_service(hass, "switch.test_relay_0", SERVICE_TURN_ON) @@ -177,26 +142,13 @@ async def test_discovery_update( ) -> None: """Update discovery message and check if relay are property updated.""" - # publish the first discovery message - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "first_test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + device_name="first_test", + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # test the available relay in the first configuration for i in range(8): @@ -206,25 +158,13 @@ async def test_discovery_update( # prepare a new message ... the same device but renamed # and with different relay configuration - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "second_test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + device_name="second_test", + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # be sure that old relay are been removed for i in range(8): @@ -245,25 +185,12 @@ async def test_disable_entity_state_change_via_mqtt( ) -> None: """Test state update via MQTT of disable entity.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Be sure that the entity relay_0 is available state = hass.states.get("switch.test_relay_0") @@ -298,12 +225,7 @@ async def test_disable_entity_state_change_via_mqtt( await hass.async_block_till_done() # Re-send the discovery message - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), - ) - await hass.async_block_till_done() + await send_discovery_message(hass, payload) # Be sure that the state is not changed state = hass.states.get("switch.test_relay_0") diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 42dcf449168..2644f0f21c6 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -856,7 +856,7 @@ async def test_client_header_issues(hass: HomeAssistant) -> None: patch("plexauth.PlexAuth.initiate_auth"), patch("plexauth.PlexAuth.token", return_value=None), patch( - "homeassistant.components.http.current_request.get", + "homeassistant.helpers.http.current_request.get", return_value=MockRequest(), ), pytest.raises( diff --git a/tests/components/pterodactyl/__init__.py b/tests/components/pterodactyl/__init__.py new file mode 100644 index 00000000000..a5b28d67ae3 --- /dev/null +++ b/tests/components/pterodactyl/__init__.py @@ -0,0 +1 @@ +"""Tests for the Pterodactyl integration.""" diff --git a/tests/components/pterodactyl/conftest.py b/tests/components/pterodactyl/conftest.py new file mode 100644 index 00000000000..62326e79207 --- /dev/null +++ b/tests/components/pterodactyl/conftest.py @@ -0,0 +1,155 @@ +"""Common fixtures for the Pterodactyl tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pydactyl.responses import PaginatedResponse +import pytest + +from homeassistant.components.pterodactyl.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_URL + +from tests.common import MockConfigEntry + +TEST_URL = "https://192.168.0.1:8080/" +TEST_API_KEY = "TestClientApiKey" +TEST_USER_INPUT = { + CONF_URL: TEST_URL, + CONF_API_KEY: TEST_API_KEY, +} +TEST_SERVER_LIST_DATA = { + "meta": {"pagination": {"total": 2, "count": 2, "per_page": 50, "current_page": 1}}, + "data": [ + { + "object": "server", + "attributes": { + "server_owner": True, + "identifier": "1", + "internal_id": 1, + "uuid": "1-1-1-1-1", + "name": "Test Server 1", + "node": "default_node", + "description": "Description of Test Server 1", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": None, + "oom_disabled": True, + }, + "invocation": "java -jar test_server1.jar", + "docker_image": "test_docker_image_1", + "egg_features": ["java_version"], + }, + }, + { + "object": "server", + "attributes": { + "server_owner": True, + "identifier": "2", + "internal_id": 2, + "uuid": "2-2-2-2-2", + "name": "Test Server 2", + "node": "default_node", + "description": "Description of Test Server 2", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": None, + "oom_disabled": True, + }, + "invocation": "java -jar test_server_2.jar", + "docker_image": "test_docker_image2", + "egg_features": ["java_version"], + }, + }, + ], +} +TEST_SERVER = { + "server_owner": True, + "identifier": "1", + "internal_id": 1, + "uuid": "1-1-1-1-1", + "name": "Test Server 1", + "node": "default_node", + "is_node_under_maintenance": False, + "sftp_details": {"ip": "192.168.0.1", "port": 2022}, + "description": "", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": None, + "oom_disabled": True, + }, + "invocation": "java -jar test.jar", + "docker_image": "test_docker_image", + "egg_features": ["eula", "java_version", "pid_limit"], + "feature_limits": {"databases": 0, "allocations": 0, "backups": 3}, + "status": None, + "is_suspended": False, + "is_installing": False, + "is_transferring": False, + "relationships": {"allocations": {...}, "variables": {...}}, +} +TEST_SERVER_UTILIZATION = { + "current_state": "running", + "is_suspended": False, + "resources": { + "memory_bytes": 1111, + "cpu_absolute": 22, + "disk_bytes": 3333, + "network_rx_bytes": 44, + "network_tx_bytes": 55, + "uptime": 6666, + }, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.pterodactyl.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create Pterodactyl mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=None, + entry_id="01234567890123456789012345678901", + title=TEST_URL, + data={ + CONF_URL: TEST_URL, + CONF_API_KEY: TEST_API_KEY, + }, + version=1, + ) + + +@pytest.fixture +def mock_pterodactyl(): + """Mock the Pterodactyl API.""" + with patch( + "homeassistant.components.pterodactyl.api.PterodactylClient", autospec=True + ) as mock: + mock.return_value.client.servers.list_servers.return_value = PaginatedResponse( + mock.return_value, "client", TEST_SERVER_LIST_DATA + ) + mock.return_value.client.servers.get_server.return_value = TEST_SERVER + mock.return_value.client.servers.get_server_utilization.return_value = ( + TEST_SERVER_UTILIZATION + ) + + yield mock.return_value diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py new file mode 100644 index 00000000000..88247085083 --- /dev/null +++ b/tests/components/pterodactyl/test_config_flow.py @@ -0,0 +1,172 @@ +"""Test the Pterodactyl config flow.""" + +from pydactyl import PterodactylClient +from pydactyl.exceptions import BadRequestError, PterodactylApiError +import pytest +from requests.exceptions import HTTPError +from requests.models import Response + +from homeassistant.components.pterodactyl.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_API_KEY, TEST_URL, TEST_USER_INPUT + +from tests.common import MockConfigEntry + + +def mock_response(): + """Mock HTTP response.""" + mock = Response() + mock.status_code = 401 + + return mock + + +@pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") +async def test_full_flow(hass: HomeAssistant) -> None: + """Test full flow without errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input=TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_URL + assert result["data"] == TEST_USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception_type", "expected_error"), + [ + (PterodactylApiError, "cannot_connect"), + (BadRequestError, "cannot_connect"), + (Exception, "unknown"), + (HTTPError(response=mock_response()), "invalid_auth"), + ], +) +async def test_recovery_after_error( + hass: HomeAssistant, + exception_type: Exception, + expected_error: str, + mock_pterodactyl: PterodactylClient, +) -> None: + """Test recovery after an error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_pterodactyl.client.servers.list_servers.side_effect = exception_type + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input=TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_pterodactyl.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_URL + assert result["data"] == TEST_USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_pterodactyl") +async def test_service_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow abort if the Pterodactyl server is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") +async def test_reauth_full_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth config flow success.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_URL] == TEST_URL + assert mock_config_entry.data[CONF_API_KEY] == TEST_API_KEY + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception_type", "expected_error"), + [ + (PterodactylApiError, "cannot_connect"), + (BadRequestError, "cannot_connect"), + (Exception, "unknown"), + (HTTPError(response=mock_response()), "invalid_auth"), + ], +) +async def test_reauth_recovery_after_error( + hass: HomeAssistant, + exception_type: Exception, + expected_error: str, + mock_config_entry: MockConfigEntry, + mock_pterodactyl: PterodactylClient, +) -> None: + """Test recovery after an error during re-authentication.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_pterodactyl.client.servers.list_servers.side_effect = exception_type + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_pterodactyl.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_URL] == TEST_URL + assert mock_config_entry.data[CONF_API_KEY] == TEST_API_KEY diff --git a/tests/components/pvoutput/test_sensor.py b/tests/components/pvoutput/test_sensor.py index fbcff94be60..36a37653efe 100644 --- a/tests/components/pvoutput/test_sensor.py +++ b/tests/components/pvoutput/test_sensor.py @@ -30,8 +30,8 @@ async def test_sensors( ) -> None: """Test the PVOutput sensors.""" - state = hass.states.get("sensor.frenck_s_solar_farm_energy_consumed") - entry = entity_registry.async_get("sensor.frenck_s_solar_farm_energy_consumed") + state = hass.states.get("sensor.frenck_s_solar_farm_energy_consumption") + entry = entity_registry.async_get("sensor.frenck_s_solar_farm_energy_consumption") assert entry assert state assert entry.unique_id == "12345_energy_consumption" @@ -40,14 +40,14 @@ async def test_sensors( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ( state.attributes.get(ATTR_FRIENDLY_NAME) - == "Frenck's Solar Farm Energy consumed" + == "Frenck's Solar Farm Energy consumption" ) assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.WATT_HOUR assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.frenck_s_solar_farm_energy_generated") - entry = entity_registry.async_get("sensor.frenck_s_solar_farm_energy_generated") + state = hass.states.get("sensor.frenck_s_solar_farm_energy_generation") + entry = entity_registry.async_get("sensor.frenck_s_solar_farm_energy_generation") assert entry assert state assert entry.unique_id == "12345_energy_generation" @@ -56,7 +56,7 @@ async def test_sensors( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ( state.attributes.get(ATTR_FRIENDLY_NAME) - == "Frenck's Solar Farm Energy generated" + == "Frenck's Solar Farm Energy generation" ) assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.WATT_HOUR @@ -78,8 +78,8 @@ async def test_sensors( assert ATTR_DEVICE_CLASS not in state.attributes assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.frenck_s_solar_farm_power_consumed") - entry = entity_registry.async_get("sensor.frenck_s_solar_farm_power_consumed") + state = hass.states.get("sensor.frenck_s_solar_farm_power_consumption") + entry = entity_registry.async_get("sensor.frenck_s_solar_farm_power_consumption") assert entry assert state assert entry.unique_id == "12345_power_consumption" @@ -87,14 +87,15 @@ async def test_sensors( assert state.state == "2500.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's Solar Farm Power consumed" + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Frenck's Solar Farm Power consumption" ) assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.frenck_s_solar_farm_power_generated") - entry = entity_registry.async_get("sensor.frenck_s_solar_farm_power_generated") + state = hass.states.get("sensor.frenck_s_solar_farm_power_generation") + entry = entity_registry.async_get("sensor.frenck_s_solar_farm_power_generation") assert entry assert state assert entry.unique_id == "12345_power_generation" @@ -103,7 +104,7 @@ async def test_sensors( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ( state.attributes.get(ATTR_FRIENDLY_NAME) - == "Frenck's Solar Farm Power generated" + == "Frenck's Solar Farm Power generation" ) assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT diff --git a/tests/components/pyload/snapshots/test_diagnostics.ambr b/tests/components/pyload/snapshots/test_diagnostics.ambr index 81a5d750bc0..d773804bf73 100644 --- a/tests/components/pyload/snapshots/test_diagnostics.ambr +++ b/tests/components/pyload/snapshots/test_diagnostics.ambr @@ -13,6 +13,7 @@ 'download': True, 'free_space': 99999999999, 'pause': False, + 'proxy': None, 'queue': 6, 'reconnect': False, 'speed': 5405963.0, diff --git a/tests/components/qbus/conftest.py b/tests/components/qbus/conftest.py index 8268d091bda..f1fd96c321b 100644 --- a/tests/components/qbus/conftest.py +++ b/tests/components/qbus/conftest.py @@ -1,5 +1,7 @@ """Test fixtures for qbus.""" +import json + import pytest from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN @@ -7,9 +9,13 @@ from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonObjectType -from .const import FIXTURE_PAYLOAD_CONFIG +from .const import FIXTURE_PAYLOAD_CONFIG, TOPIC_CONFIG -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import ( + MockConfigEntry, + async_fire_mqtt_message, + load_json_object_fixture, +) @pytest.fixture @@ -31,3 +37,18 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: def payload_config() -> JsonObjectType: """Return the config topic payload.""" return load_json_object_fixture(FIXTURE_PAYLOAD_CONFIG, DOMAIN) + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> None: + """Set up the integration.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) + await hass.async_block_till_done() diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json index e2c7f463e4e..fc204c975ad 100644 --- a/tests/components/qbus/fixtures/payload_config.json +++ b/tests/components/qbus/fixtures/payload_config.json @@ -46,7 +46,7 @@ { "id": "UL15", "location": "Media room", - "locationId": 0, + "locationId": 1, "name": "MEDIA ROOM", "originalName": "MEDIA ROOM", "refId": "000001/28", @@ -65,6 +65,40 @@ "write": true } } + }, + { + "id": "UL20", + "location": "Living", + "locationId": 0, + "name": "LIVING TH", + "originalName": "LIVING TH", + "refId": "000001/120", + "type": "thermo", + "actions": {}, + "properties": { + "currRegime": { + "enumValues": ["MANUEEL", "VORST", "ECONOMY", "COMFORT", "NACHT"], + "read": true, + "type": "enumString", + "write": true + }, + "currTemp": { + "max": 35, + "min": 0, + "read": true, + "step": 0.5, + "type": "number", + "write": false + }, + "setTemp": { + "max": 35, + "min": 0, + "read": true, + "step": 0.5, + "type": "number", + "write": true + } + } } ] } diff --git a/tests/components/qbus/test_climate.py b/tests/components/qbus/test_climate.py new file mode 100644 index 00000000000..d521e310984 --- /dev/null +++ b/tests/components/qbus/test_climate.py @@ -0,0 +1,228 @@ +"""Test Qbus light entities.""" + +from datetime import timedelta +from unittest.mock import MagicMock, call + +import pytest + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntity, + HVACAction, + HVACMode, +) +from homeassistant.components.qbus.climate import STATE_REQUEST_DELAY +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_mqtt_message, async_fire_time_changed +from tests.typing import MqttMockHAClient + +_CURRENT_TEMPERATURE = 21.5 +_SET_TEMPERATURE = 20.5 +_REGIME = "COMFORT" + +_PAYLOAD_CLIMATE_STATE_TEMP = ( + f'{{"id":"UL20","properties":{{"setTemp":{_SET_TEMPERATURE}}},"type":"event"}}' +) +_PAYLOAD_CLIMATE_STATE_TEMP_FULL = f'{{"id":"UL20","properties":{{"currRegime":"MANUEEL","currTemp":{_CURRENT_TEMPERATURE},"setTemp":{_SET_TEMPERATURE}}},"type":"state"}}' + +_PAYLOAD_CLIMATE_STATE_PRESET = ( + f'{{"id":"UL20","properties":{{"currRegime":"{_REGIME}"}},"type":"event"}}' +) +_PAYLOAD_CLIMATE_STATE_PRESET_FULL = f'{{"id":"UL20","properties":{{"currRegime":"{_REGIME}","currTemp":{_CURRENT_TEMPERATURE},"setTemp":22.0}},"type":"state"}}' + +_PAYLOAD_CLIMATE_SET_TEMP = f'{{"id": "UL20", "type": "state", "properties": {{"setTemp": {_SET_TEMPERATURE}}}}}' +_PAYLOAD_CLIMATE_SET_PRESET = ( + '{"id": "UL20", "type": "state", "properties": {"currRegime": "COMFORT"}}' +) + +_TOPIC_CLIMATE_STATE = "cloudapp/QBUSMQTTGW/UL1/UL20/state" +_TOPIC_CLIMATE_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL20/setState" +_TOPIC_GET_STATE = "cloudapp/QBUSMQTTGW/getState" + +_CLIMATE_ENTITY_ID = "climate.living_th" + + +async def test_climate( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate temperature & preset.""" + + # Set temperature + mqtt_mock.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_TEMPERATURE: _SET_TEMPERATURE, + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_CLIMATE_SET_STATE, _PAYLOAD_CLIMATE_SET_TEMP, 0, False + ) + + # Simulate a partial state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + + # Check state + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] is None + assert entity.attributes[ATTR_PRESET_MODE] == "MANUEEL" + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # After a delay, a full state request should've been sent + _wait_and_assert_state_request(hass, mqtt_mock) + + # Simulate a full state response + async_fire_mqtt_message( + hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP_FULL + ) + await hass.async_block_till_done() + + # Check state after full state response + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == "MANUEEL" + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # Set preset + mqtt_mock.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_PRESET_MODE: _REGIME, + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_CLIMATE_SET_STATE, _PAYLOAD_CLIMATE_SET_PRESET, 0, False + ) + + # Simulate a partial state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_PRESET) + await hass.async_block_till_done() + + # Check state + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == _REGIME + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # After a delay, a full state request should've been sent + _wait_and_assert_state_request(hass, mqtt_mock) + + # Simulate a full state response + async_fire_mqtt_message( + hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_PRESET_FULL + ) + await hass.async_block_till_done() + + # Check state after full state response + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == 22.0 + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == _REGIME + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert entity.state == HVACMode.HEAT + + +async def test_climate_when_invalid_state_received( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate when no valid state is received.""" + + platform: EntityPlatform = hass.data["entity_components"][CLIMATE_DOMAIN] + entity: ClimateEntity = next( + ( + entity + for entity in platform.entities + if entity.entity_id == _CLIMATE_ENTITY_ID + ), + None, + ) + + assert entity + entity.async_schedule_update_ha_state = MagicMock() + + # Simulate state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, "") + await hass.async_block_till_done() + + entity.async_schedule_update_ha_state.assert_not_called() + + +async def test_climate_with_fast_subsequent_changes( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate with fast subsequent changes.""" + + # Simulate two subsequent partial state responses + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + + # State request should be requested only once + _wait_and_assert_state_request(hass, mqtt_mock) + + +async def test_climate_with_unknown_preset( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate with passing an unknown preset value.""" + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_PRESET_MODE: "What is cooler than being cool?", + }, + blocking=True, + ) + + +def _wait_and_assert_state_request( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + mqtt_mock.reset_mock() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(STATE_REQUEST_DELAY)) + mqtt_mock.async_publish.assert_has_calls( + [call(_TOPIC_GET_STATE, '["UL20"]', 0, False)], + any_order=True, + ) diff --git a/tests/components/qbus/test_light.py b/tests/components/qbus/test_light.py index c64219f1269..2db2c622289 100644 --- a/tests/components/qbus/test_light.py +++ b/tests/components/qbus/test_light.py @@ -1,7 +1,5 @@ """Test Qbus light entities.""" -import json - from homeassistant.components.light import ( ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN, @@ -10,11 +8,8 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.util.json import JsonObjectType -from .const import TOPIC_CONFIG - -from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient # 186 = 73% (rounded) @@ -44,17 +39,10 @@ _LIGHT_ENTITY_ID = "light.media_room" async def test_light( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, - mock_config_entry: MockConfigEntry, - payload_config: JsonObjectType, + setup_integration: None, ) -> None: """Test turning on and off.""" - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) - await hass.async_block_till_done() - # Switch ON mqtt_mock.reset_mock() await hass.services.async_call( diff --git a/tests/components/qbus/test_switch.py b/tests/components/qbus/test_switch.py index 83bb667e4eb..ddb63e933da 100644 --- a/tests/components/qbus/test_switch.py +++ b/tests/components/qbus/test_switch.py @@ -1,7 +1,5 @@ """Test Qbus switch entities.""" -import json - from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -9,11 +7,8 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.util.json import JsonObjectType -from .const import TOPIC_CONFIG - -from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient _PAYLOAD_SWITCH_STATE_ON = '{"id":"UL10","properties":{"value":true},"type":"state"}' @@ -34,17 +29,10 @@ _SWITCH_ENTITY_ID = "switch.living" async def test_switch_turn_on_off( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, - mock_config_entry: MockConfigEntry, - payload_config: JsonObjectType, + setup_integration: None, ) -> None: """Test turning on and off.""" - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) - await hass.async_block_till_done() - # Switch ON mqtt_mock.reset_mock() await hass.services.async_call( diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py index 31630913a70..b7e811b69ef 100644 --- a/tests/components/rainforest_eagle/test_sensor.py +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -10,17 +10,17 @@ async def test_sensors_200(hass: HomeAssistant, setup_rainforest_200) -> None: """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.eagle_200_meter_power_demand") + demand = hass.states.get("sensor.eagle_200_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_200_total_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.eagle_200_total_meter_energy_received") + received = hass.states.get("sensor.eagle_200_total_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" @@ -33,7 +33,7 @@ async def test_sensors_200(hass: HomeAssistant, setup_rainforest_200) -> None: assert len(hass.states.async_all()) == 4 - price = hass.states.get("sensor.eagle_200_meter_price") + price = hass.states.get("sensor.eagle_200_energy_price") assert price is not None assert price.state == "0.053990" assert price.attributes["unit_of_measurement"] == "USD/kWh" @@ -43,17 +43,17 @@ async def test_sensors_100(hass: HomeAssistant, setup_rainforest_100) -> None: """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.eagle_100_meter_power_demand") + demand = hass.states.get("sensor.eagle_100_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.eagle_100_total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_100_total_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.eagle_100_total_meter_energy_received") + received = hass.states.get("sensor.eagle_100_total_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" diff --git a/tests/components/rainforest_raven/snapshots/test_sensor.ambr b/tests/components/rainforest_raven/snapshots/test_sensor.ambr index 618766c1613..bf369d374e0 100644 --- a/tests/components/rainforest_raven/snapshots/test_sensor.ambr +++ b/tests/components/rainforest_raven/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[sensor.raven_device_meter_power_demand-entry] +# name: test_sensors[sensor.raven_device_power_demand-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,7 +14,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.raven_device_meter_power_demand', + 'entity_id': 'sensor.raven_device_power_demand', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -26,7 +26,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Meter power demand', + 'original_name': 'Power demand', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, @@ -35,23 +35,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.raven_device_meter_power_demand-state] +# name: test_sensors[sensor.raven_device_power_demand-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'RAVEn Device Meter power demand', + 'friendly_name': 'RAVEn Device Power demand', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.raven_device_meter_power_demand', + 'entity_id': 'sensor.raven_device_power_demand', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1.2345', }) # --- -# name: test_sensors[sensor.raven_device_meter_price-entry] +# name: test_sensors[sensor.raven_device_energy_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -66,7 +66,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.raven_device_meter_price', + 'entity_id': 'sensor.raven_device_energy_price', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -78,33 +78,33 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Meter price', + 'original_name': 'Energy price', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'meter_price', + 'translation_key': 'energy_price', 'unique_id': '1234567890abcdef.PriceCluster.price', 'unit_of_measurement': 'USD/kWh', }) # --- -# name: test_sensors[sensor.raven_device_meter_price-state] +# name: test_sensors[sensor.raven_device_energy_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'RAVEn Device Meter price', + 'friendly_name': 'RAVEn Device Energy price', 'rate_label': 'Set by user', 'state_class': , 'tier': 3, 'unit_of_measurement': 'USD/kWh', }), 'context': , - 'entity_id': 'sensor.raven_device_meter_price', + 'entity_id': 'sensor.raven_device_energy_price', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.10', }) # --- -# name: test_sensors[sensor.raven_device_meter_signal_strength-entry] +# name: test_sensors[sensor.raven_device_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,7 +119,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.raven_device_meter_signal_strength', + 'entity_id': 'sensor.raven_device_signal_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -131,7 +131,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Meter signal strength', + 'original_name': 'Signal strength', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, @@ -140,23 +140,23 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[sensor.raven_device_meter_signal_strength-state] +# name: test_sensors[sensor.raven_device_signal_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'channel': 13, - 'friendly_name': 'RAVEn Device Meter signal strength', + 'friendly_name': 'RAVEn Device Signal strength', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.raven_device_meter_signal_strength', + 'entity_id': 'sensor.raven_device_signal_strength', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '100', }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_delivered-entry] +# name: test_sensors[sensor.raven_device_total_energy_delivered-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -171,7 +171,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.raven_device_total_meter_energy_delivered', + 'entity_id': 'sensor.raven_device_total_energy_delivered', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -183,7 +183,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total meter energy delivered', + 'original_name': 'Total energy delivered', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, @@ -192,23 +192,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_delivered-state] +# name: test_sensors[sensor.raven_device_total_energy_delivered-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'RAVEn Device Total meter energy delivered', + 'friendly_name': 'RAVEn Device Total energy delivered', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.raven_device_total_meter_energy_delivered', + 'entity_id': 'sensor.raven_device_total_energy_delivered', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '23456.7890', }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_received-entry] +# name: test_sensors[sensor.raven_device_total_energy_received-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -223,7 +223,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.raven_device_total_meter_energy_received', + 'entity_id': 'sensor.raven_device_total_energy_received', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -235,7 +235,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total meter energy received', + 'original_name': 'Total energy received', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, @@ -244,16 +244,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_received-state] +# name: test_sensors[sensor.raven_device_total_energy_received-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'RAVEn Device Total meter energy received', + 'friendly_name': 'RAVEn Device Total energy received', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.raven_device_total_meter_energy_received', + 'entity_id': 'sensor.raven_device_total_energy_received', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 352a2345052..99d6705e4a4 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -87,6 +87,7 @@ async def test_validate_db_schema_fix_float_issue( "created_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION", "mean DOUBLE PRECISION", + "mean_weight DOUBLE PRECISION", "min DOUBLE PRECISION", "max DOUBLE PRECISION", "last_reset_ts DOUBLE PRECISION", diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 28eb097f576..d381c225275 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -35,7 +35,8 @@ from homeassistant.components.recorder.db_schema import ( StatesMeta, ) from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask -from homeassistant.const import UnitOfTemperature +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import DEGREE, UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import recorder as recorder_helper from homeassistant.util import dt as dt_util @@ -290,6 +291,7 @@ def record_states( sns2 = "sensor.test2" sns3 = "sensor.test3" sns4 = "sensor.test4" + sns5 = "sensor.wind_direction" sns1_attr = { "device_class": "temperature", "state_class": "measurement", @@ -302,6 +304,11 @@ def record_states( } sns3_attr = {"device_class": "temperature"} sns4_attr = {} + sns5_attr = { + "device_class": SensorDeviceClass.WIND_DIRECTION, + "state_class": SensorStateClass.MEASUREMENT_ANGLE, + "unit_of_measurement": DEGREE, + } def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -315,7 +322,7 @@ def record_states( three = two + timedelta(seconds=30 * 5) four = three + timedelta(seconds=14 * 5) - states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} + states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: [], sns5: []} with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) @@ -324,6 +331,7 @@ def record_states( states[sns2].append(set_state(sns2, "10", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "10", attributes=sns4_attr)) + states[sns5].append(set_state(sns5, "10", attributes=sns5_attr)) freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( @@ -335,12 +343,14 @@ def record_states( states[sns2].append(set_state(sns2, "15", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "15", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "15", attributes=sns4_attr)) + states[sns5].append(set_state(sns5, "350", attributes=sns5_attr)) freezer.move_to(three) states[sns1].append(set_state(sns1, "20", attributes=sns1_attr)) states[sns2].append(set_state(sns2, "20", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "20", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "20", attributes=sns4_attr)) + states[sns5].append(set_state(sns5, "5", attributes=sns5_attr)) return zero, four, states diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index daa7fb6977c..9c19a1c7405 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -583,6 +583,8 @@ class StatisticsBase: last_reset_ts = Column(TIMESTAMP_TYPE) state = Column(DOUBLE_TYPE) sum = Column(DOUBLE_TYPE) + # *** Not originally in v32, only added for tests. Added in v49 + mean_weight = Column(DOUBLE_TYPE) @classmethod def from_stats(cls, metadata_id: int, stats: StatisticData) -> Self: diff --git a/tests/components/recorder/table_managers/test_statistics_meta.py b/tests/components/recorder/table_managers/test_statistics_meta.py index 66edb84c3ef..1af60b71ed5 100644 --- a/tests/components/recorder/table_managers/test_statistics_meta.py +++ b/tests/components/recorder/table_managers/test_statistics_meta.py @@ -2,10 +2,19 @@ from __future__ import annotations +import logging +import threading + import pytest from homeassistant.components import recorder +from homeassistant.components.recorder.db_schema import StatisticsMeta +from homeassistant.components.recorder.models import ( + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.util import session_scope +from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant from tests.typing import RecorderInstanceGenerator @@ -55,3 +64,78 @@ async def test_unsafe_calls_to_statistics_meta_manager( session, statistic_ids=["light.kitchen"], ) + + +async def test_invalid_mean_types( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test passing invalid mean types will be skipped and logged.""" + instance = await async_setup_recorder_instance( + hass, {recorder.CONF_COMMIT_INTERVAL: 0} + ) + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + valid_metadata: dict[str, tuple[int, StatisticMetaData]] = { + "sensor.energy": ( + 1, + { + "mean_type": StatisticMeanType.NONE, + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.energy", + "unit_of_measurement": "kWh", + }, + ), + "sensor.wind_direction": ( + 2, + { + "mean_type": StatisticMeanType.CIRCULAR, + "has_mean": False, + "has_sum": False, + "name": "Wind direction", + "source": "recorder", + "statistic_id": "sensor.wind_direction", + "unit_of_measurement": DEGREE, + }, + ), + "sensor.wind_speed": ( + 3, + { + "mean_type": StatisticMeanType.ARITHMETIC, + "has_mean": True, + "has_sum": False, + "name": "Wind speed", + "source": "recorder", + "statistic_id": "sensor.wind_speed", + "unit_of_measurement": "km/h", + }, + ), + } + manager = instance.statistics_meta_manager + with instance.get_session() as session: + for _, metadata in valid_metadata.values(): + session.add(StatisticsMeta.from_meta(metadata)) + + # Add invalid mean type + session.add( + StatisticsMeta( + statistic_id="sensor.invalid", + source="recorder", + has_sum=False, + name="Invalid", + mean_type=12345, + ) + ) + session.commit() + + # Check that the invalid mean type was skipped + assert manager.get_many(session) == valid_metadata + assert ( + "homeassistant.components.recorder.table_managers.statistics_meta", + logging.WARNING, + "Invalid mean type found for statistic_id: sensor.invalid, mean_type: 12345. Skipping", + ) in caplog.record_tuples diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 012e227c11a..7fd73aaf735 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -1538,6 +1538,7 @@ async def test_stats_timestamp_conversion_is_reentrant( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": process_timestamp(one_year_ago).replace(tzinfo=None), @@ -1553,6 +1554,7 @@ async def test_stats_timestamp_conversion_is_reentrant( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1568,6 +1570,7 @@ async def test_stats_timestamp_conversion_is_reentrant( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": process_timestamp(one_month_ago).replace(tzinfo=None), @@ -1705,6 +1708,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1720,6 +1724,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1735,6 +1740,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1758,6 +1764,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1773,6 +1780,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1788,6 +1796,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1932,6 +1941,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1947,6 +1957,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1962,6 +1973,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1985,6 +1997,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ed883c5403e..ed754723426 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -12,6 +12,7 @@ from homeassistant.components import recorder from homeassistant.components.recorder import Recorder, history, statistics from homeassistant.components.recorder.db_schema import StatisticsShortTerm from homeassistant.components.recorder.models import ( + StatisticMeanType, datetime_to_timestamp_or_none, process_timestamp, ) @@ -123,32 +124,38 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) assert stats == {} - for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): + for kwargs in ({}, {"statistic_ids": ["sensor.test1", "sensor.wind_direction"]}): stats = statistics_during_period(hass, zero, period="5minute", **kwargs) assert stats == {} - stats = get_last_short_term_statistics( - hass, - 0, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {} + for sensor in ("sensor.test1", "sensor.wind_direction"): + stats = get_last_short_term_statistics( + hass, + 0, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {} do_adhoc_statistics(hass, start=zero) do_adhoc_statistics(hass, start=four) await async_wait_recording_done(hass) - metadata = get_metadata(hass, statistic_ids={"sensor.test1", "sensor.test2"}) - assert metadata["sensor.test1"][1]["has_mean"] is True - assert metadata["sensor.test1"][1]["has_sum"] is False - assert metadata["sensor.test2"][1]["has_mean"] is True - assert metadata["sensor.test2"][1]["has_sum"] is False + metadata = get_metadata( + hass, statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"} + ) + for sensor, mean_type in ( + ("sensor.test1", StatisticMeanType.ARITHMETIC), + ("sensor.test2", StatisticMeanType.ARITHMETIC), + ("sensor.wind_direction", StatisticMeanType.CIRCULAR), + ): + assert metadata[sensor][1]["mean_type"] is mean_type + assert metadata[sensor][1]["has_sum"] is False expected_1 = { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), @@ -168,11 +175,39 @@ async def test_compile_hourly_statistics( expected_stats1 = [expected_1, expected_2] expected_stats2 = [expected_1, expected_2] + expected_stats_wind_direction1 = { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + } + expected_stats_wind_direction2 = { + "start": process_timestamp(four).timestamp(), + "end": process_timestamp(four + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(5), + "min": None, + "max": None, + "last_reset": None, + } + expected_stats_wind_direction = [ + expected_stats_wind_direction1, + expected_stats_wind_direction2, + ] + # Test statistics_during_period stats = statistics_during_period( - hass, zero, period="5minute", statistic_ids={"sensor.test1", "sensor.test2"} + hass, + zero, + period="5minute", + statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"}, ) - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Test statistics_during_period with a far future start and end date future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) @@ -181,7 +216,7 @@ async def test_compile_hourly_statistics( future, end_time=future, period="5minute", - statistic_ids={"sensor.test1", "sensor.test2"}, + statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"}, ) assert stats == {} @@ -191,9 +226,13 @@ async def test_compile_hourly_statistics( zero, end_time=future, period="5minute", - statistic_ids={"sensor.test1", "sensor.test2"}, + statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"}, ) - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } stats = statistics_during_period( hass, zero, statistic_ids={"sensor.test2"}, period="5minute" @@ -206,32 +245,39 @@ async def test_compile_hourly_statistics( assert stats == {} # Test get_last_short_term_statistics and get_latest_short_term_statistics - stats = get_last_short_term_statistics( - hass, - 0, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {} + for sensor, expected in ( + ("sensor.test1", expected_2), + ("sensor.wind_direction", expected_stats_wind_direction2), + ): + stats = get_last_short_term_statistics( + hass, + 0, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {} - stats = get_last_short_term_statistics( - hass, - 1, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {"sensor.test1": [expected_2]} + stats = get_last_short_term_statistics( + hass, + 1, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {sensor: [expected]} with session_scope(hass=hass, read_only=True) as session: stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) - assert stats == {"sensor.test1": [expected_2]} + assert stats == { + "sensor.test1": [expected_2], + "sensor.wind_direction": [expected_stats_wind_direction2], + } # Now wipe the latest_short_term_statistics_ids table and test again # to make sure we can rebuild the missing data @@ -241,13 +287,15 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) - assert stats == {"sensor.test1": [expected_2]} + assert stats == { + "sensor.test1": [expected_2], + "sensor.wind_direction": [expected_stats_wind_direction2], + } metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) - with session_scope(hass=hass, read_only=True) as session: stats = get_latest_short_term_statistics_with_session( hass, @@ -258,23 +306,44 @@ async def test_compile_hourly_statistics( ) assert stats == {"sensor.test1": [expected_2]} - stats = get_last_short_term_statistics( - hass, - 2, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, + # Test with multiple metadata ids + metadata = get_metadata( + hass, statistic_ids={"sensor.test1", "sensor.wind_direction"} ) - assert stats == {"sensor.test1": expected_stats1[::-1]} + with session_scope(hass=hass, read_only=True) as session: + stats = get_latest_short_term_statistics_with_session( + hass, + session, + {"sensor.test1", "sensor.wind_direction"}, + {"last_reset", "max", "mean", "min", "state", "sum"}, + metadata=metadata, + ) + assert stats == { + "sensor.test1": [expected_2], + "sensor.wind_direction": [expected_stats_wind_direction2], + } - stats = get_last_short_term_statistics( - hass, - 3, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {"sensor.test1": expected_stats1[::-1]} + for sensor, expected in ( + ("sensor.test1", expected_stats1[::-1]), + ("sensor.wind_direction", expected_stats_wind_direction[::-1]), + ): + stats = get_last_short_term_statistics( + hass, + 2, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {sensor: expected} + + stats = get_last_short_term_statistics( + hass, + 3, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {sensor: expected} stats = get_last_short_term_statistics( hass, @@ -291,7 +360,7 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) assert stats == {} @@ -306,7 +375,7 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) assert stats == {} @@ -460,15 +529,35 @@ async def test_rename_entity( expected_stats1 = [expected_1] expected_stats2 = [expected_1] expected_stats99 = [expected_1] + expected_stats_wind_direction = [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } entity_registry.async_update_entity("sensor.test1", new_entity_id="sensor.test99") await async_wait_recording_done(hass) stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test99": expected_stats99, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } async def test_statistics_during_period_set_back_compat( @@ -544,9 +633,25 @@ async def test_rename_entity_collision( } expected_stats1 = [expected_1] expected_stats2 = [expected_1] + expected_stats_wind_direction = [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Insert metadata for sensor.test99 metadata_1 = { @@ -567,7 +672,11 @@ async def test_rename_entity_collision( # Statistics failed to migrate due to the collision stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Verify the safeguard in the states meta manager was hit assert ( @@ -631,9 +740,25 @@ async def test_rename_entity_collision_states_meta_check_disabled( } expected_stats1 = [expected_1] expected_stats2 = [expected_1] + expected_stats_wind_direction = [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Insert metadata for sensor.test99 metadata_1 = { @@ -660,7 +785,11 @@ async def test_rename_entity_collision_states_meta_check_disabled( # Statistics failed to migrate due to the collision stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Verify the filter_unique_constraint_integrity_error safeguard was hit assert "Blocked attempt to insert duplicated statistic rows" in caplog.text @@ -786,6 +915,7 @@ async def test_import_statistics( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -800,6 +930,7 @@ async def test_import_statistics( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -876,6 +1007,7 @@ async def test_import_statistics( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy renamed", @@ -890,6 +1022,7 @@ async def test_import_statistics( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy renamed", "source": source, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index a4e35bc8753..2460de994ec 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -1,11 +1,14 @@ """The tests for sensor recorder platform.""" +from collections.abc import Iterable import datetime from datetime import timedelta +import math from statistics import fmean import sys from unittest.mock import ANY, patch +from _pytest.python_api import ApproxBase from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -13,7 +16,14 @@ import pytest from homeassistant.components import recorder from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.db_schema import Statistics, StatisticsShortTerm +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( + DEG_TO_RAD, + RAD_TO_DEG, async_add_external_statistics, get_last_statistics, get_latest_short_term_statistics_with_session, @@ -24,6 +34,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.components.recorder.util import session_scope from homeassistant.components.recorder.websocket_api import UNIT_SCHEMA from homeassistant.components.sensor import UNIT_CONVERTERS +from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component @@ -247,12 +258,12 @@ async def test_statistics_during_period( @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("recorder_mock") @pytest.mark.parametrize("offset", [0, 1, 2]) async def test_statistic_during_period( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - offset, + offset: int, ) -> None: """Test statistic_during_period.""" now = dt_util.utcnow() @@ -307,7 +318,7 @@ async def test_statistic_during_period( ) imported_metadata = { - "has_mean": False, + "has_mean": True, "has_sum": True, "name": "Total imported energy", "source": "recorder", @@ -655,7 +666,7 @@ async def test_statistic_during_period( hass, session, {"sensor.test"}, - {"last_reset", "max", "mean", "min", "state", "sum"}, + {"last_reset", "state", "sum"}, ) start = imported_stats_5min[-1]["start"].timestamp() end = start + (5 * 60) @@ -672,18 +683,394 @@ async def test_statistic_during_period( } +def _circular_mean(values: Iterable[StatisticData]) -> dict[str, float]: + sin_sum = 0 + cos_sum = 0 + for x in values: + mean = x.get("mean") + assert mean is not None + sin_sum += math.sin(mean * DEG_TO_RAD) + cos_sum += math.cos(mean * DEG_TO_RAD) + + return { + "mean": (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360, + "mean_weight": math.sqrt(sin_sum**2 + cos_sum**2), + } + + +def _circular_mean_approx( + values: Iterable[StatisticData], tolerance: float | None = None +) -> ApproxBase: + return pytest.approx(_circular_mean(values)["mean"], abs=tolerance) + + +@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("recorder_mock") +@pytest.mark.parametrize("offset", [0, 1, 2]) +@pytest.mark.parametrize( + ("step_size", "tolerance"), + [ + (123.456, 1e-4), + # In this case the angles are uniformly distributed and the mean is undefined. + # This edge case is not handled by the current implementation, but the test + # checks the behavior is consistent. + # We could consider returning None in this case, or returning also an estimate + # of the variance. + (120, 10), + ], +) +async def test_statistic_during_period_circular_mean( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + offset: int, + step_size: float, + tolerance: float, +) -> None: + """Test statistic_during_period.""" + now = dt_util.utcnow() + + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + zero = now + start = zero.replace(minute=offset * 5, second=0, microsecond=0) + timedelta( + hours=-3 + ) + + imported_stats_5min: list[StatisticData] = [ + { + "start": (start + timedelta(minutes=5 * i)), + "mean": (step_size * i) % 360, + "mean_weight": 1, + } + for i in range(39) + ] + + imported_stats = [] + slice_end = 12 - offset + imported_stats.append( + { + "start": imported_stats_5min[0]["start"].replace(minute=0), + **_circular_mean(imported_stats_5min[0:slice_end]), + } + ) + for i in range(2): + slice_start = i * 12 + (12 - offset) + slice_end = (i + 1) * 12 + (12 - offset) + assert imported_stats_5min[slice_start]["start"].minute == 0 + imported_stats.append( + { + "start": imported_stats_5min[slice_start]["start"], + **_circular_mean(imported_stats_5min[slice_start:slice_end]), + } + ) + + imported_metadata: StatisticMetaData = { + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": "Wind direction", + "source": "recorder", + "statistic_id": "sensor.test", + "unit_of_measurement": DEGREE, + } + + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats, + Statistics, + ) + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats_5min, + StatisticsShortTerm, + ) + 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_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": now.isoformat(), + "end_time": now.isoformat(), + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": None, + "mean": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[:] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min, tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics_5min[:] + start_time = ( + dt_util.parse_datetime("2022-10-21T04:00:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + end_time = ( + dt_util.parse_datetime("2022-10-21T07:15:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min, tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics_5min[:] + start_time = ( + dt_util.parse_datetime("2022-10-21T04:00:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + end_time = ( + dt_util.parse_datetime("2022-10-21T08:20:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min, tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[26:] + start_time = ( + dt_util.parse_datetime("2022-10-21T06:10:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[26]["start"].isoformat() == start_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[26:], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics_5min[26:] + start_time = ( + dt_util.parse_datetime("2022-10-21T06:09:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[26:], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[:26] + end_time = ( + dt_util.parse_datetime("2022-10-21T06:10:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[26]["start"].isoformat() == end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "end_time": end_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[:26], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[26:32] (less than a full hour) + start_time = ( + dt_util.parse_datetime("2022-10-21T06:10:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[26]["start"].isoformat() == start_time + end_time = ( + dt_util.parse_datetime("2022-10-21T06:40:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[32]["start"].isoformat() == end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[26:32], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics[2:] + imported_statistics_5min[36:] + start_time = "2022-10-21T06:00:00+00:00" + assert imported_stats_5min[24 - offset]["start"].isoformat() == start_time + assert imported_stats[2]["start"].isoformat() == start_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[24 - offset :], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics[2:] + imported_statistics_5min[36:] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "rolling_window": { + "duration": {"hours": 1, "minutes": 25}, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[24 - offset :], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics[2:3] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "rolling_window": { + "duration": {"hours": 1}, + "offset": {"minutes": -25}, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + slice_start = 24 - offset + slice_end = 36 - offset + assert response["result"] == { + "mean": _circular_mean_approx( + imported_stats_5min[slice_start:slice_end], tolerance + ), + "max": None, + "min": None, + "change": None, + } + + # Test we can get only selected types + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "types": ["mean"], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min, tolerance), + } + + @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) async def test_statistic_during_period_hole( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test statistic_during_period when there are holes in the data.""" - stat_id = 1 - - def next_id(): - nonlocal stat_id - stat_id += 1 - return stat_id - now = dt_util.utcnow() await async_recorder_block_till_done(hass) @@ -704,7 +1091,7 @@ async def test_statistic_during_period_hole( ] imported_metadata = { - "has_mean": False, + "has_mean": True, "has_sum": True, "name": "Total imported energy", "source": "recorder", @@ -830,6 +1217,156 @@ async def test_statistic_during_period_hole( } +@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("recorder_mock") +async def test_statistic_during_period_hole_circular_mean( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test statistic_during_period when there are holes in the data.""" + now = dt_util.utcnow() + + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + zero = now + start = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=-18) + + imported_stats: list[StatisticData] = [ + { + "start": (start + timedelta(hours=3 * i)), + "mean": (123.456 * i) % 360, + "mean_weight": 1, + } + for i in range(6) + ] + + imported_metadata: StatisticMetaData = { + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": "Wind direction", + "source": "recorder", + "statistic_id": "sensor.test", + "unit_of_measurement": DEGREE, + } + + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats, + Statistics, + ) + await async_wait_recording_done(hass) + + # This should include imported_stats[:] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[:]), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_stats[:] + start_time = "2022-10-20T13:00:00+00:00" + end_time = "2022-10-21T05:00:00+00:00" + assert imported_stats[0]["start"].isoformat() == start_time + assert imported_stats[-1]["start"].isoformat() < end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[:]), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_stats[:] + start_time = "2022-10-20T13:00:00+00:00" + end_time = "2022-10-21T08:20:00+00:00" + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[:]), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_stats[1:4] + start_time = "2022-10-20T16:00:00+00:00" + end_time = "2022-10-20T23:00:00+00:00" + assert imported_stats[1]["start"].isoformat() == start_time + assert imported_stats[3]["start"].isoformat() < end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[1:4]), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_stats[1:4] + start_time = "2022-10-20T15:00:00+00:00" + end_time = "2022-10-21T00:00:00+00:00" + assert imported_stats[1]["start"].isoformat() > start_time + assert imported_stats[3]["start"].isoformat() < end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[1:4]), + "max": None, + "min": None, + "change": None, + } + + @pytest.mark.parametrize( "frozen_time", [ @@ -897,7 +1434,7 @@ async def test_statistic_during_period_partial_overlap( statId = "sensor.test_overlapping" imported_metadata = { - "has_mean": False, + "has_mean": True, "has_sum": True, "name": "Total imported energy overlapping", "source": "recorder", @@ -1766,6 +2303,7 @@ async def test_list_statistic_ids( """Test list_statistic_ids.""" now = get_start_time(dt_util.utcnow()) has_mean = attributes["state_class"] == "measurement" + mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE has_sum = not has_mean hass.config.units = units @@ -1791,6 +2329,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1813,6 +2352,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1838,6 +2378,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1859,6 +2400,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1939,6 +2481,7 @@ async def test_list_statistic_ids_unit_change( """Test list_statistic_ids.""" now = get_start_time(dt_util.utcnow()) has_mean = attributes["state_class"] == "measurement" + mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE has_sum = not has_mean await async_setup_component(hass, "sensor", {}) @@ -1966,6 +2509,7 @@ async def test_list_statistic_ids_unit_change( "statistic_id": "sensor.test", "display_unit_of_measurement": statistics_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1987,6 +2531,7 @@ async def test_list_statistic_ids_unit_change( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -2208,6 +2753,7 @@ async def test_update_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2235,6 +2781,7 @@ async def test_update_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": new_display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2324,6 +2871,7 @@ async def test_change_statistics_unit( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2375,6 +2923,7 @@ async def test_change_statistics_unit( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2428,6 +2977,7 @@ async def test_change_statistics_unit( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2455,6 +3005,7 @@ async def test_change_statistics_unit_errors( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2774,6 +3325,7 @@ async def test_get_statistics_metadata( """Test get_statistics_metadata.""" now = get_start_time(dt_util.utcnow()) has_mean = attributes["state_class"] == "measurement" + mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE has_sum = not has_mean hass.config.units = units @@ -2843,6 +3395,7 @@ async def test_get_statistics_metadata( "statistic_id": "test:total_gas", "display_unit_of_measurement": unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": "Total imported energy", "source": "test", @@ -2874,6 +3427,7 @@ async def test_get_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": attributes["unit_of_measurement"], "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -2901,6 +3455,7 @@ async def test_get_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": attributes["unit_of_measurement"], "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -2995,6 +3550,7 @@ async def test_import_statistics( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3009,6 +3565,7 @@ async def test_import_statistics( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -3213,6 +3770,7 @@ async def test_adjust_sum_statistics_energy( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3227,6 +3785,7 @@ async def test_adjust_sum_statistics_energy( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -3406,6 +3965,7 @@ async def test_adjust_sum_statistics_gas( { "display_unit_of_measurement": "m³", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3420,6 +3980,7 @@ async def test_adjust_sum_statistics_gas( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -3617,6 +4178,7 @@ async def test_adjust_sum_statistics_errors( { "display_unit_of_measurement": state_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3631,6 +4193,7 @@ async def test_adjust_sum_statistics_errors( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, diff --git a/tests/components/remote_calendar/snapshots/test_diagnostics.ambr b/tests/components/remote_calendar/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..de955f8a2aa --- /dev/null +++ b/tests/components/remote_calendar/snapshots/test_diagnostics.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'ics': ''' + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:*** + DTSTART:19970714T170000Z + DTEND:19970715T040000Z + END:VEVENT + END:VCALENDAR + ''', + 'now': '2023-06-04T18:00:00-06:00', + 'system_timezone': 'tzlocal()', + 'timezone': 'America/Regina', + }) +# --- diff --git a/tests/components/remote_calendar/test_config_flow.py b/tests/components/remote_calendar/test_config_flow.py index 626bc2c6e03..9aff1594db3 100644 --- a/tests/components/remote_calendar/test_config_flow.py +++ b/tests/components/remote_calendar/test_config_flow.py @@ -45,6 +45,35 @@ async def test_form_import_ics(hass: HomeAssistant, ics_content: str) -> None: } +@respx.mock +async def test_form_import_webcal(hass: HomeAssistant, ics_content: str) -> None: + """Test we get the import form.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: "webcal://some.calendar.com/calendar.ics", + }, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == CALENDAR_NAME + assert result2["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + @pytest.mark.parametrize( ("side_effect"), [ @@ -136,8 +165,17 @@ async def test_unsupported_inputs( ## and then the exception isn't raised anymore. +@pytest.mark.parametrize( + ("http_status", "error"), + [ + (401, "cannot_connect"), + (403, "forbidden"), + ], +) @respx.mock -async def test_form_http_status_error(hass: HomeAssistant, ics_content: str) -> None: +async def test_form_http_status_error( + hass: HomeAssistant, ics_content: str, http_status: int, error: str +) -> None: """Test we http status.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -145,7 +183,7 @@ async def test_form_http_status_error(hass: HomeAssistant, ics_content: str) -> assert result["type"] is FlowResultType.FORM respx.get(CALENDER_URL).mock( return_value=Response( - status_code=403, + status_code=http_status, ) ) @@ -157,7 +195,7 @@ async def test_form_http_status_error(hass: HomeAssistant, ics_content: str) -> }, ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": error} respx.get(CALENDER_URL).mock( return_value=Response( status_code=200, diff --git a/tests/components/remote_calendar/test_diagnostics.py b/tests/components/remote_calendar/test_diagnostics.py new file mode 100644 index 00000000000..428369b1180 --- /dev/null +++ b/tests/components/remote_calendar/test_diagnostics.py @@ -0,0 +1,39 @@ +"""Test the remote calendar diagnostics.""" + +import datetime + +from httpx import Response +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import CALENDER_URL + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@respx.mock +@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5)) +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + ics_content: str, +) -> None: + """Test config entry diagnostics.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + await setup_integration(hass, config_entry) + await hass.async_block_till_done() + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == snapshot diff --git a/tests/components/renault/conftest.py b/tests/components/renault/conftest.py index 9be41eb7ba0..dd3c4896264 100644 --- a/tests/components/renault/conftest.py +++ b/tests/components/renault/conftest.py @@ -1,9 +1,8 @@ """Provide common Renault fixtures.""" -from collections.abc import Generator, Iterator +from collections.abc import AsyncGenerator, Generator import contextlib from types import MappingProxyType -from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -51,7 +50,7 @@ def get_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture(name="patch_renault_account") -async def patch_renault_account(hass: HomeAssistant) -> RenaultAccount: +async def patch_renault_account(hass: HomeAssistant) -> AsyncGenerator[RenaultAccount]: """Create a Renault account.""" renault_account = RenaultAccount( MOCK_ACCOUNT_ID, @@ -68,7 +67,7 @@ async def patch_renault_account(hass: HomeAssistant) -> RenaultAccount: @pytest.fixture(name="patch_get_vehicles") -def patch_get_vehicles(vehicle_type: str): +def patch_get_vehicles(vehicle_type: str) -> Generator[None]: """Mock fixtures.""" with patch( "renault_api.renault_account.RenaultAccount.get_vehicles", @@ -123,149 +122,100 @@ def _get_fixtures(vehicle_type: str) -> MappingProxyType: } +@contextlib.contextmanager +def patch_get_vehicle_data() -> Generator[dict[str, AsyncMock]]: + """Mock get_vehicle_data methods.""" + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status" + ) as get_battery_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode" + ) as get_charge_mode, + patch("renault_api.renault_vehicle.RenaultVehicle.get_cockpit") as get_cockpit, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status" + ) as get_hvac_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_location" + ) as get_location, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_lock_status" + ) as get_lock_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_res_state" + ) as get_res_state, + ): + yield { + "battery_status": get_battery_status, + "charge_mode": get_charge_mode, + "cockpit": get_cockpit, + "hvac_status": get_hvac_status, + "location": get_location, + "lock_status": get_lock_status, + "res_state": get_res_state, + } + + @pytest.fixture(name="fixtures_with_data") -def patch_fixtures_with_data(vehicle_type: str): +def patch_fixtures_with_data(vehicle_type: str) -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" mock_fixtures = _get_fixtures(vehicle_type) - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - return_value=mock_fixtures["battery_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - return_value=mock_fixtures["charge_mode"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - return_value=mock_fixtures["cockpit"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - return_value=mock_fixtures["hvac_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - return_value=mock_fixtures["location"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - return_value=mock_fixtures["lock_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - return_value=mock_fixtures["res_state"], - ), - ): - yield + with patch_get_vehicle_data() as patches: + for key, value in patches.items(): + value.return_value = mock_fixtures[key] + yield patches @pytest.fixture(name="fixtures_with_no_data") -def patch_fixtures_with_no_data(): +def patch_fixtures_with_no_data() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" mock_fixtures = _get_fixtures("") - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - return_value=mock_fixtures["battery_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - return_value=mock_fixtures["charge_mode"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - return_value=mock_fixtures["cockpit"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - return_value=mock_fixtures["hvac_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - return_value=mock_fixtures["location"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - return_value=mock_fixtures["lock_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - return_value=mock_fixtures["res_state"], - ), - ): - yield - - -@contextlib.contextmanager -def _patch_fixtures_with_side_effect(side_effect: Any) -> Iterator[None]: - """Mock fixtures.""" - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - side_effect=side_effect, - ), - ): - yield + with patch_get_vehicle_data() as patches: + for key, value in patches.items(): + value.return_value = mock_fixtures[key] + yield patches @pytest.fixture(name="fixtures_with_access_denied_exception") -def patch_fixtures_with_access_denied_exception(): +def patch_fixtures_with_access_denied_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" access_denied_exception = exceptions.AccessDeniedException( "err.func.403", "Access is denied for this resource", ) - with _patch_fixtures_with_side_effect(access_denied_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = access_denied_exception + yield patches @pytest.fixture(name="fixtures_with_invalid_upstream_exception") -def patch_fixtures_with_invalid_upstream_exception(): +def patch_fixtures_with_invalid_upstream_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" invalid_upstream_exception = exceptions.InvalidUpstreamException( "err.tech.500", "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", ) - with _patch_fixtures_with_side_effect(invalid_upstream_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = invalid_upstream_exception + yield patches @pytest.fixture(name="fixtures_with_not_supported_exception") -def patch_fixtures_with_not_supported_exception(): +def patch_fixtures_with_not_supported_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" not_supported_exception = exceptions.NotSupportedException( "err.tech.501", "This feature is not technically supported by this gateway", ) - with _patch_fixtures_with_side_effect(not_supported_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = not_supported_exception + yield patches diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index d69ab5c0b7f..bce50ec4fbf 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -1,19 +1,25 @@ """Tests for Renault sensors.""" from collections.abc import Generator +import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest +from renault_api.kamereon.exceptions import QuotaLimitException from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from . import check_device_registry, check_entities_unavailable +from .conftest import _get_fixtures, patch_get_vehicle_data from .const import MOCK_VEHICLES +from tests.common import async_fire_time_changed + pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -150,3 +156,88 @@ async def test_sensor_not_supported( check_device_registry(device_registry, mock_vehicle["expected_device"]) assert len(entity_registry.entities) == 0 + + +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_sensor_throttling_during_setup( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_type: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for Renault sensors with a throttling error during setup.""" + mock_fixtures = _get_fixtures(vehicle_type) + with patch_get_vehicle_data() as patches: + for key, get_data_mock in patches.items(): + get_data_mock.return_value = mock_fixtures[key] + get_data_mock.side_effect = QuotaLimitException( + "err.func.wired.overloaded", "You have reached your quota limit" + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Initial state + entity_id = "sensor.reg_number_battery" + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # Test QuotaLimitException recovery, with new battery level + for get_data_mock in patches.values(): + get_data_mock.side_effect = None + patches["battery_status"].return_value.batteryLevel = 55 + freezer.tick(datetime.timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "55" + + +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_sensor_throttling_after_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_type: str, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for Renault sensors with a throttling error during setup.""" + mock_fixtures = _get_fixtures(vehicle_type) + with patch_get_vehicle_data() as patches: + for key, get_data_mock in patches.items(): + get_data_mock.return_value = mock_fixtures[key] + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Initial state + entity_id = "sensor.reg_number_battery" + assert hass.states.get(entity_id).state == "60" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled: scan skipped" not in caplog.text + + # Test QuotaLimitException state + caplog.clear() + for get_data_mock in patches.values(): + get_data_mock.side_effect = QuotaLimitException( + "err.func.wired.overloaded", "You have reached your quota limit" + ) + freezer.tick(datetime.timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "60" + assert hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" in caplog.text + assert "Renault hub currently throttled: scan skipped" in caplog.text + + # Test QuotaLimitException recovery, with new battery level + caplog.clear() + for get_data_mock in patches.values(): + get_data_mock.side_effect = None + patches["battery_status"].return_value.batteryLevel = 55 + freezer.tick(datetime.timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "55" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" not in caplog.text + assert "Renault hub currently throttled: scan skipped" not in caplog.text diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index f2474d640d8..21acced3d1d 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -35,6 +35,7 @@ TEST_PASSWORD = "password" TEST_PASSWORD2 = "new_password" TEST_MAC = "aa:bb:cc:dd:ee:ff" TEST_MAC2 = "ff:ee:dd:cc:bb:aa" +TEST_MAC_CAM = "11:22:33:44:55:66" DHCP_FORMATTED_MAC = "aabbccddeeff" TEST_UID = "ABC1234567D89EFG" TEST_UID_CAM = "DEF7654321D89GHT" @@ -142,6 +143,7 @@ def reolink_connect_class() -> Generator[MagicMock]: # Disable tcp push by default for tests host_mock.baichuan.port = TEST_BC_PORT host_mock.baichuan.events_active = False + host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.day_night_state.return_value = "day" host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index c6507fa36c1..dd70376d658 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -67,6 +67,48 @@ async def test_number( reolink_connect.set_volume.reset_mock(side_effect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_smart_ai_number( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test number entity with smart ai sensitivity.""" + reolink_connect.baichuan.smart_ai_sensitivity.return_value = 80 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_AI_crossline_zone1_sensitivity" + + assert hass.states.get(entity_id).state == "80" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + reolink_connect.baichuan.set_smart_ai.assert_called_with( + 0, "crossline", 0, sensitivity=50 + ) + + reolink_connect.baichuan.set_smart_ai.side_effect = InvalidParameterError( + "Test error" + ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + + reolink_connect.baichuan.set_smart_ai.reset_mock(side_effect=True) + + async def test_host_number( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index a6cfe862963..d48362516b8 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from reolink_aio.exceptions import ReolinkError +from reolink_aio.exceptions import ApiError, ReolinkError from reolink_aio.software_version import NewSoftwareVersion from homeassistant.components.reolink.update import POLL_AFTER_INSTALL, POLL_PROGRESS @@ -144,6 +144,17 @@ async def test_update_firm( blocking=True, ) + reolink_connect.update_firmware.side_effect = ApiError( + "Test error", translation_key="firmware_rate_limit" + ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + # test _async_update_future reolink_connect.camera_sw_version.return_value = "v3.3.0.226_23031644" reolink_connect.firmware_update_available.return_value = False diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py index f66f4682b98..ef66d471801 100644 --- a/tests/components/reolink/test_util.py +++ b/tests/components/reolink/test_util.py @@ -38,51 +38,59 @@ from tests.common import MockConfigEntry [ ( ApiError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="api_error"), + ), + ( + ApiError("Test error", translation_key="firmware_rate_limit"), + HomeAssistantError(translation_key="firmware_rate_limit"), + ), + ( + ApiError("Test error", translation_key="not_in_strings.json"), + HomeAssistantError(translation_key="api_error"), ), ( CredentialsInvalidError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="invalid_credentials"), ), ( InvalidContentTypeError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="invalid_content_type"), ), ( InvalidParameterError("Test error"), - ServiceValidationError, + ServiceValidationError(translation_key="invalid_parameter"), ), ( LoginError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="login_error"), ), ( NoDataError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="no_data"), ), ( NotSupportedError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="not_supported"), ), ( ReolinkConnectionError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="connection_error"), ), ( ReolinkError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="unexpected"), ), ( ReolinkTimeoutError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="timeout"), ), ( SubscriptionError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="subscription_error"), ), ( UnexpectedDataError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="unexpected_data"), ), ], ) @@ -91,7 +99,7 @@ async def test_try_function( config_entry: MockConfigEntry, reolink_connect: MagicMock, side_effect: ReolinkError, - expected: Exception, + expected: HomeAssistantError, ) -> None: """Test try_function error translations using number entity.""" reolink_connect.volume.return_value = 80 @@ -104,7 +112,7 @@ async def test_try_function( entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" reolink_connect.set_volume.side_effect = side_effect - with pytest.raises(expected): + with pytest.raises(expected.__class__) as err: await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -112,4 +120,6 @@ async def test_try_function( blocking=True, ) + assert err.value.translation_key == expected.translation_key + reolink_connect.set_volume.reset_mock(side_effect=True) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index fcd469ca10a..d807e35710b 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -3,15 +3,13 @@ from collections.abc import Generator from copy import deepcopy import pathlib -import shutil +import tempfile from typing import Any from unittest.mock import Mock, patch -import uuid import pytest from roborock import RoborockCategory, RoomMapping from roborock.code_mappings import DyadError, RoborockDyadStateCode, ZeoError, ZeoState -from roborock.containers import NetworkInfo from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from roborock.version_a01_apis import RoborockMqttClientA01 @@ -20,7 +18,6 @@ from homeassistant.components.roborock.const import ( CONF_USER_DATA, DOMAIN, ) -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant @@ -30,8 +27,8 @@ from .mock_data import ( MAP_DATA, MULTI_MAP_LIST, NETWORK_INFO, - NETWORK_INFO_2, PROP, + ROBOROCK_RRUID, SCENES, USER_DATA, USER_EMAIL, @@ -89,13 +86,6 @@ def bypass_api_client_fixture() -> None: yield -def cycle_network_info() -> Generator[NetworkInfo]: - """Return the appropriate network info for the corresponding device.""" - while True: - yield NETWORK_INFO - yield NETWORK_INFO_2 - - @pytest.fixture(name="bypass_api_fixture") def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: """Skip calls to the API.""" @@ -107,7 +97,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: ), patch( "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - side_effect=cycle_network_info(), + return_value=NETWORK_INFO, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", @@ -199,18 +189,28 @@ def bypass_api_fixture_v1_only(bypass_api_fixture) -> None: yield +@pytest.fixture(name="config_entry_data") +def config_entry_data_fixture() -> dict[str, Any]: + """Fixture that returns the unique id for the config entry.""" + return { + CONF_USERNAME: USER_EMAIL, + CONF_USER_DATA: USER_DATA.as_dict(), + CONF_BASE_URL: BASE_URL, + } + + @pytest.fixture -def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: +def mock_roborock_entry( + hass: HomeAssistant, config_entry_data: dict[str, Any] +) -> MockConfigEntry: """Create a Roborock Entry that has not been setup.""" mock_entry = MockConfigEntry( domain=DOMAIN, title=USER_EMAIL, - data={ - CONF_USERNAME: USER_EMAIL, - CONF_USER_DATA: USER_DATA.as_dict(), - CONF_BASE_URL: BASE_URL, - }, - unique_id=USER_EMAIL, + data=config_entry_data, + unique_id=ROBOROCK_RRUID, + version=1, + minor_version=2, ) mock_entry.add_to_hass(hass) return mock_entry @@ -222,36 +222,40 @@ def mock_platforms() -> list[Platform]: return [] +@pytest.fixture(autouse=True) +async def mock_patforms_fixture( + hass: HomeAssistant, + platforms: list[Platform], +) -> Generator[None]: + """Set up the Roborock platform.""" + with patch("homeassistant.components.roborock.PLATFORMS", platforms): + yield + + @pytest.fixture async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, - cleanup_map_storage: pathlib.Path, - platforms: list[Platform], ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" - with patch("homeassistant.components.roborock.PLATFORMS", platforms): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - await hass.async_block_till_done() - yield mock_roborock_entry + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + return mock_roborock_entry -@pytest.fixture(autouse=True) -async def cleanup_map_storage( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +@pytest.fixture(autouse=True, name="storage_path") +async def storage_path_fixture( + hass: HomeAssistant, ) -> Generator[pathlib.Path]: """Test cleanup, remove any map storage persisted during the test.""" - tmp_path = str(uuid.uuid4()) - with patch( - "homeassistant.components.roborock.roborock_storage.STORAGE_PATH", new=tmp_path - ): - storage_path = ( - pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id - ) - yield storage_path - # We need to first unload the config entry because unloading it will - # persist any unsaved maps to storage. - if mock_roborock_entry.state is ConfigEntryState.LOADED: - await hass.config_entries.async_unload(mock_roborock_entry.entry_id) - shutil.rmtree(str(storage_path), ignore_errors=True) + with tempfile.TemporaryDirectory() as tmp_path: + + def get_storage_path(_: HomeAssistant, entry_id: str) -> pathlib.Path: + return pathlib.Path(tmp_path) / entry_id + + with patch( + "homeassistant.components.roborock.roborock_storage._storage_path_prefix", + new=get_storage_path, + ): + yield pathlib.Path(tmp_path) diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 507e8060653..cf4f167ef7f 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -28,6 +28,7 @@ USER_EMAIL = "user@domain.com" BASE_URL = "https://usiot.roborock.com" +ROBOROCK_RRUID = "roboborock-userid-abc-123" USER_DATA = UserData.from_dict( { "tuyaname": "abc123", @@ -35,7 +36,7 @@ USER_DATA = UserData.from_dict( "uid": 123456, "tokentype": "", "token": "abc123", - "rruid": "abc123", + "rruid": ROBOROCK_RRUID, "region": "us", "countrycode": "1", "country": "US", @@ -1120,10 +1121,10 @@ PROP = DeviceProp( ) NETWORK_INFO = NetworkInfo( - ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc", bssid="bssid", rssi=90 + ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc:cc", bssid="bssid", rssi=90 ) NETWORK_INFO_2 = NetworkInfo( - ip="123.232.12.2", ssid="wifi", mac="ac:cc:cc:cc:cd", bssid="bssid", rssi=90 + ip="123.232.12.2", ssid="wifi", mac="ac:cc:cc:cc:cd:cc", bssid="bssid", rssi=90 ) MULTI_MAP_LIST = MultiMapsList.from_dict( diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 313824e70ec..26ecb729312 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -357,7 +357,7 @@ }), 'network_info': dict({ 'bssid': '**REDACTED**', - 'ip': '123.232.12.2', + 'ip': '123.232.12.1', 'mac': '**REDACTED**', 'rssi': 90, 'ssid': 'wifi', diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 1bcb72c2f5b..7958f17a696 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -16,11 +16,12 @@ from vacuum_map_parser_base.config.drawable import Drawable from homeassistant import config_entries from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN, DRAWABLES -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL +from .mock_data import MOCK_CONFIG, NETWORK_INFO, ROBOROCK_RRUID, USER_DATA, USER_EMAIL from tests.common import MockConfigEntry @@ -63,6 +64,7 @@ async def test_config_flow_success( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -127,6 +129,7 @@ async def test_config_flow_failures_request_code( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -188,6 +191,7 @@ async def test_config_flow_failures_code_login( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -255,6 +259,7 @@ async def test_reauth_flow( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + assert mock_roborock_entry.unique_id == ROBOROCK_RRUID assert mock_roborock_entry.data["user_data"]["rriot"]["s"] == "new_password_hash" @@ -263,7 +268,8 @@ async def test_account_already_configured( bypass_api_fixture, mock_roborock_entry: MockConfigEntry, ) -> None: - """Handle the config flow and make sure it succeeds.""" + """Ensure the same account cannot be setup twice.""" + assert mock_roborock_entry.unique_id == ROBOROCK_RRUID with patch( "homeassistant.components.roborock.async_setup_entry", return_value=True ): @@ -279,5 +285,121 @@ async def test_account_already_configured( result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) + assert result["step_id"] == "code" + assert result["type"] is FlowResultType.FORM + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=USER_DATA, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured_account" + + +async def test_reauth_wrong_account( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Ensure that reauthentication must use the same account.""" + + # Start reauth + result = mock_roborock_entry.async_start_reauth(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ): + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USER_EMAIL} + ) + + assert result["step_id"] == "code" + assert result["type"] is FlowResultType.FORM + new_user_data = deepcopy(USER_DATA) + new_user_data.rruid = "new_rruid" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=new_user_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +async def test_discovery_not_setup( + hass: HomeAssistant, + bypass_api_fixture, +) -> None: + """Handle the config flow and make sure it succeeds.""" + with ( + patch("homeassistant.components.roborock.async_setup_entry", return_value=True), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip=NETWORK_INFO.ip, + macaddress=NETWORK_INFO.mac.replace(":", ""), + hostname="roborock-vacuum-a72", + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USER_EMAIL} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "code" + assert result["errors"] == {} + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=USER_DATA, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID + assert result["title"] == USER_EMAIL + assert result["data"] == MOCK_CONFIG + assert result["result"] + + +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_discovery_already_setup( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Handle aborting if the device is already setup.""" + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip=NETWORK_INFO.ip, + macaddress=NETWORK_INFO.mac.replace(":", ""), + hostname="roborock-vacuum-a72", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index 94976ba92f5..dec4e0a62d4 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -13,6 +13,7 @@ from homeassistant.components.roborock.const import ( V1_LOCAL_IN_CLEANING_INTERVAL, V1_LOCAL_NOT_CLEANING_INTERVAL, ) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -21,6 +22,12 @@ from .mock_data import PROP from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SENSOR] + + @pytest.mark.parametrize( ("interval", "in_cleaning"), [ diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 226eea816b9..a1bcfc462e4 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -3,6 +3,7 @@ from copy import deepcopy from http import HTTPStatus import pathlib +from typing import Any from unittest.mock import patch import pytest @@ -20,7 +21,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component -from .mock_data import HOME_DATA, NETWORK_INFO +from .mock_data import ( + HOME_DATA, + NETWORK_INFO, + NETWORK_INFO_2, + ROBOROCK_RRUID, + USER_EMAIL, +) from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -174,7 +181,7 @@ async def test_remove_from_hass( bypass_api_fixture, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, - cleanup_map_storage: pathlib.Path, + storage_path: pathlib.Path, ) -> None: """Test that removing from hass removes any existing images.""" @@ -184,17 +191,18 @@ async def test_remove_from_hass( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK - assert not cleanup_map_storage.exists() + config_entry_storage = storage_path / setup_entry.entry_id + assert not config_entry_storage.exists() # Flush to disk await hass.config_entries.async_unload(setup_entry.entry_id) - assert cleanup_map_storage.exists() - paths = list(cleanup_map_storage.walk()) + assert config_entry_storage.exists() + paths = list(config_entry_storage.walk()) assert len(paths) == 4 # Two map image and two directories await hass.config_entries.async_remove(setup_entry.entry_id) # After removal, directories should be empty. - assert not cleanup_map_storage.exists() + assert not config_entry_storage.exists() @pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) @@ -202,7 +210,7 @@ async def test_oserror_remove_image( hass: HomeAssistant, bypass_api_fixture, setup_entry: MockConfigEntry, - cleanup_map_storage: pathlib.Path, + storage_path: pathlib.Path, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, ) -> None: @@ -215,11 +223,12 @@ async def test_oserror_remove_image( assert resp.status == HTTPStatus.OK # Image content is saved when unloading - assert not cleanup_map_storage.exists() + config_entry_storage = storage_path / setup_entry.entry_id + assert not config_entry_storage.exists() await hass.config_entries.async_unload(setup_entry.entry_id) - assert cleanup_map_storage.exists() - paths = list(cleanup_map_storage.walk()) + assert config_entry_storage.exists() + paths = list(config_entry_storage.walk()) assert len(paths) == 4 # Two map image and two directories with patch( @@ -298,6 +307,7 @@ async def test_no_user_agreement( assert mock_roborock_entry.error_reason_translation_key == "no_user_agreement" +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_stale_device( hass: HomeAssistant, bypass_api_fixture, @@ -305,7 +315,11 @@ async def test_stale_device( device_registry: DeviceRegistry, ) -> None: """Test that we remove a device if it no longer is given by home_data.""" - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) assert mock_roborock_entry.state is ConfigEntryState.LOADED existing_devices = device_registry.devices.get_devices_for_config_entry_id( mock_roborock_entry.entry_id @@ -314,9 +328,15 @@ async def test_stale_device( hd = deepcopy(HOME_DATA) hd.devices = [hd.devices[0]] - with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", - return_value=hd, + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=hd, + ), + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ), ): await hass.config_entries.async_reload(mock_roborock_entry.entry_id) await hass.async_block_till_done() @@ -329,6 +349,7 @@ async def test_stale_device( # therefore not deleted. +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_no_stale_device( hass: HomeAssistant, bypass_api_fixture, @@ -336,7 +357,11 @@ async def test_no_stale_device( device_registry: DeviceRegistry, ) -> None: """Test that we don't remove a device if fails to setup.""" - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) assert mock_roborock_entry.state is ConfigEntryState.LOADED existing_devices = device_registry.devices.get_devices_for_config_entry_id( mock_roborock_entry.entry_id @@ -353,3 +378,25 @@ async def test_no_stale_device( mock_roborock_entry.entry_id ) assert len(new_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo + + +async def test_migrate_config_entry_unique_id( + hass: HomeAssistant, + bypass_api_fixture, + config_entry_data: dict[str, Any], +) -> None: + """Test migrating the config entry unique id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=USER_EMAIL, + data=config_entry_data, + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.unique_id == ROBOROCK_RRUID diff --git a/tests/components/roku/test_binary_sensor.py b/tests/components/roku/test_binary_sensor.py index ad27a857101..c3aec4f0968 100644 --- a/tests/components/roku/test_binary_sensor.py +++ b/tests/components/roku/test_binary_sensor.py @@ -50,7 +50,7 @@ async def test_roku_binary_sensors( assert entry.unique_id == f"{UPNP_SERIAL}_supports_ethernet" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_ON - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports ethernet" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports Ethernet" assert ATTR_DEVICE_CLASS not in state.attributes state = hass.states.get("binary_sensor.my_roku_3_supports_find_remote") @@ -125,7 +125,7 @@ async def test_rokutv_binary_sensors( assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_ON assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports ethernet' + state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports Ethernet' ) assert ATTR_DEVICE_CLASS not in state.attributes diff --git a/tests/components/rss_feed_template/test_init.py b/tests/components/rss_feed_template/test_init.py index 802fbb2244b..3b708b577af 100644 --- a/tests/components/rss_feed_template/test_init.py +++ b/tests/components/rss_feed_template/test_init.py @@ -1,6 +1,5 @@ """The tests for the rss_feed_api component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus from aiohttp.test_utils import TestClient @@ -14,13 +13,11 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def mock_http_client( - event_loop: AbstractEventLoop, +async def mock_http_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Set up test fixture.""" - loop = event_loop config = { "rss_feed_template": { "testfeed": { @@ -35,8 +32,8 @@ def mock_http_client( } } - loop.run_until_complete(async_setup_component(hass, "rss_feed_template", config)) - return loop.run_until_complete(hass_client()) + await async_setup_component(hass, "rss_feed_template", config) + return await hass_client() async def test_get_nonexistant_feed(mock_http_client) -> None: diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index b162200f95e..9666e29579b 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -24,6 +24,7 @@ from homeassistant.components.sensor import ( async_rounded_state, async_update_suggested_units, ) +from homeassistant.components.sensor.const import STATE_CLASS_UNITS from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -2005,6 +2006,7 @@ async def test_non_numeric_device_class_with_unit_of_measurement( SensorDeviceClass.VOLUME, SensorDeviceClass.WATER, SensorDeviceClass.WEIGHT, + SensorDeviceClass.WIND_DIRECTION, SensorDeviceClass.WIND_SPEED, ], ) @@ -2035,6 +2037,37 @@ async def test_device_classes_with_invalid_unit_of_measurement( ) in caplog.text +@pytest.mark.parametrize( + "state_class", + [SensorStateClass.MEASUREMENT_ANGLE], +) +async def test_state_classes_with_invalid_unit_of_measurement( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + state_class: SensorStateClass, +) -> None: + """Test error when unit of measurement is not valid for used state class.""" + entity0 = MockSensor( + name="Test", + native_value="1.0", + state_class=state_class, + native_unit_of_measurement="INVALID!", + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + units = { + str(unit) if unit else "no unit of measurement" + for unit in STATE_CLASS_UNITS.get(state_class, set()) + } + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + f"Sensor sensor.test ({entity0.__class__}) is using native unit of " + "measurement 'INVALID!' which is not a valid unit " + f"for the state class ('{state_class}') it is using; expected one of {units};" + ) in caplog.text + + @pytest.mark.parametrize( ("device_class", "state_class", "unit"), [ diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 1dd8fb4905a..43f185f939a 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,7 +1,8 @@ """The tests for sensor recorder platform.""" -from collections.abc import Iterable +from collections.abc import Callable, Iterable from datetime import datetime, timedelta +import logging import math from statistics import mean from typing import Any, Literal @@ -26,17 +27,30 @@ from homeassistant.components.recorder.db_schema import ( ) from homeassistant.components.recorder.models import ( StatisticData, + StatisticMeanType, StatisticMetaData, process_timestamp, ) from homeassistant.components.recorder.statistics import ( + DEG_TO_RAD, + RAD_TO_DEG, async_import_statistics, get_metadata, list_statistic_ids, ) from homeassistant.components.recorder.util import get_instance, session_scope -from homeassistant.components.sensor import ATTR_OPTIONS, DOMAIN, SensorDeviceClass -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE +from homeassistant.components.sensor import ( + ATTR_OPTIONS, + DOMAIN, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.components.sensor.recorder import ( + MEAN_TYPE_CHANGED_ISSUE, + STATE_CLASS_REMOVED_ISSUE, + UNITS_CHANGED_ISSUE, +) +from homeassistant.const import ATTR_FRIENDLY_NAME, DEGREE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -98,6 +112,13 @@ KW_SENSOR_ATTRIBUTES = { "state_class": "measurement", "unit_of_measurement": "kW", } +WIND_DIRECTION_ATTRIBUTES = { + "device_class": SensorDeviceClass.WIND_DIRECTION, + "state_class": SensorStateClass.MEASUREMENT_ANGLE, + "unit_of_measurement": DEGREE, +} +WIND_DIRECTION_STATES_SEQ = [350, 0, 15] +TEMP_STATES_SEQ = [-10, 15, 30, 60] @pytest.fixture @@ -281,6 +302,7 @@ async def test_compile_hourly_statistics( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -306,6 +328,64 @@ async def test_compile_hourly_statistics( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics for measurement_angle.""" + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + with freeze_time(zero) as freezer: + four, states = await async_record_states( + hass, + freezer, + zero, + "sensor.test1", + WIND_DIRECTION_ATTRIBUTES, + seq=WIND_DIRECTION_STATES_SEQ, + ) + await async_wait_recording_done(hass) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + await async_wait_recording_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(0.5802544), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( ( "device_class", @@ -349,7 +429,7 @@ async def test_compile_hourly_statistics_with_some_same_last_updated( "unit_of_measurement": state_unit, } attributes = dict(attributes) - seq = [-10, 15, 30, 60] + seq = TEMP_STATES_SEQ async def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -395,6 +475,7 @@ async def test_compile_hourly_statistics_with_some_same_last_updated( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -420,33 +501,167 @@ async def test_compile_hourly_statistics_with_some_same_last_updated( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_with_some_same_last_updated_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics with the some of the same last updated value for measurement_angle. + + If the last updated value is the same we will have a zero duration. + """ + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + entity_id = "sensor.test1" + seq = [350, 2, 15, 345] + + async def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.async_set(entity_id, state, **kwargs) + await async_wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + + states = {entity_id: []} + with freeze_time(one) as freezer: + states[entity_id].append( + await set_state( + entity_id, str(seq[0]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + + # Record two states at the exact same time + freezer.move_to(two) + states[entity_id].append( + await set_state( + entity_id, str(seq[1]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + states[entity_id].append( + await set_state( + entity_id, str(seq[2]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + + freezer.move_to(three) + states[entity_id].append( + await set_state( + entity_id, str(seq[3]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + await async_wait_recording_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(6.274605), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( ( - "device_class", - "state_unit", + "attributes", "display_unit", "statistics_unit", "unit_class", "mean", "min", "max", + "mean_type", + "seq", ), [ - ("temperature", "°C", "°C", "°C", "temperature", 60, -10, 60), - ("temperature", "°F", "°F", "°F", "temperature", 60, -10, 60), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°C", + }, + "°C", + "°C", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°F", + }, + "°F", + "°F", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + WIND_DIRECTION_ATTRIBUTES, + DEGREE, + DEGREE, + None, + 15, + None, + None, + StatisticMeanType.CIRCULAR, + [350, 0, 355, 15], + ), ], ) async def test_compile_hourly_statistics_with_all_same_last_updated( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - device_class, - state_unit, - display_unit, - statistics_unit, - unit_class, - mean, - min, - max, + attributes: dict[str, Any], + display_unit: str, + statistics_unit: str, + unit_class: str | None, + mean: float | None, + min: float | None, + max: float | None, + mean_type: StatisticMeanType, + seq: list[float], ) -> None: """Test compiling hourly statistics with the all of the same last updated value. @@ -457,13 +672,6 @@ async def test_compile_hourly_statistics_with_all_same_last_updated( # Wait for the sensor recorder platform to be added await async_recorder_block_till_done(hass) entity_id = "sensor.test1" - attributes = { - "device_class": device_class, - "state_class": "measurement", - "unit_of_measurement": state_unit, - } - attributes = dict(attributes) - seq = [-10, 15, 30, 60] async def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -503,7 +711,8 @@ async def test_compile_hourly_statistics_with_all_same_last_updated( { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": True, + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": False, "name": None, "source": "recorder", @@ -531,31 +740,72 @@ async def test_compile_hourly_statistics_with_all_same_last_updated( @pytest.mark.parametrize( ( - "device_class", - "state_unit", + "attributes", "display_unit", "statistics_unit", "unit_class", "mean", "min", "max", + "mean_type", + "seq", ), [ - ("temperature", "°C", "°C", "°C", "temperature", 60, -10, 60), - ("temperature", "°F", "°F", "°F", "temperature", 60, -10, 60), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°C", + }, + "°C", + "°C", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°F", + }, + "°F", + "°F", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + WIND_DIRECTION_ATTRIBUTES, + DEGREE, + DEGREE, + None, + 15, + None, + None, + StatisticMeanType.CIRCULAR, + [350, 0, 355, 15], + ), ], ) async def test_compile_hourly_statistics_only_state_is_at_end_of_period( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - device_class, - state_unit, - display_unit, - statistics_unit, - unit_class, - mean, - min, - max, + attributes: dict[str, Any], + display_unit: str, + statistics_unit: str, + unit_class: str | None, + mean: float | None, + min: float | None, + max: float | None, + mean_type: StatisticMeanType, + seq: list[float], ) -> None: """Test compiling hourly statistics when the only states are at end of period.""" zero = get_start_time(dt_util.utcnow()) @@ -563,13 +813,6 @@ async def test_compile_hourly_statistics_only_state_is_at_end_of_period( # Wait for the sensor recorder platform to be added await async_recorder_block_till_done(hass) entity_id = "sensor.test1" - attributes = { - "device_class": device_class, - "state_class": "measurement", - "unit_of_measurement": state_unit, - } - attributes = dict(attributes) - seq = [-10, 15, 30, 60] async def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -611,7 +854,8 @@ async def test_compile_hourly_statistics_only_state_is_at_end_of_period( { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": True, + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": False, "name": None, "source": "recorder", @@ -695,6 +939,7 @@ async def test_compile_hourly_statistics_purged_state_changes( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -781,6 +1026,7 @@ async def test_compile_hourly_statistics_ignore_future_state( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -873,6 +1119,7 @@ async def test_compile_hourly_statistics_wrong_unit( "statistic_id": "sensor.test1", "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -882,6 +1129,7 @@ async def test_compile_hourly_statistics_wrong_unit( { "display_unit_of_measurement": "invalid", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -892,6 +1140,7 @@ async def test_compile_hourly_statistics_wrong_unit( { "display_unit_of_measurement": None, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -903,6 +1152,7 @@ async def test_compile_hourly_statistics_wrong_unit( "statistic_id": "sensor.test6", "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -913,6 +1163,7 @@ async def test_compile_hourly_statistics_wrong_unit( "statistic_id": "sensor.test7", "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -1084,6 +1335,7 @@ async def test_compile_hourly_sum_statistics_amount( "statistic_id": "sensor.test1", "display_unit_of_measurement": statistics_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1288,6 +1540,7 @@ async def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1397,6 +1650,7 @@ async def test_compile_hourly_sum_statistics_amount_invalid_last_reset( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1493,6 +1747,7 @@ async def test_compile_hourly_sum_statistics_nan_inf_state( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1636,6 +1891,7 @@ async def test_compile_hourly_sum_statistics_negative_state( assert { "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1737,6 +1993,7 @@ async def test_compile_hourly_sum_statistics_total_no_reset( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1850,6 +2107,7 @@ async def test_compile_hourly_sum_statistics_total_increasing( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1976,6 +2234,7 @@ async def test_compile_hourly_sum_statistics_total_increasing_small_dip( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2080,6 +2339,7 @@ async def test_compile_hourly_energy_statistics_unsupported( "statistic_id": "sensor.test1", "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2182,6 +2442,7 @@ async def test_compile_hourly_energy_statistics_multiple( "statistic_id": "sensor.test1", "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2192,6 +2453,7 @@ async def test_compile_hourly_energy_statistics_multiple( "statistic_id": "sensor.test2", "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2202,6 +2464,7 @@ async def test_compile_hourly_energy_statistics_multiple( "statistic_id": "sensor.test3", "display_unit_of_measurement": "Wh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2384,8 +2647,64 @@ async def test_compile_hourly_statistics_unchanged( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_unchanged_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics, with no changes during the hour for measurement_angle.""" + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + with freeze_time(zero) as freezer: + four, states = await async_record_states( + hass, + freezer, + zero, + "sensor.test1", + WIND_DIRECTION_ATTRIBUTES, + seq=WIND_DIRECTION_STATES_SEQ, + ) + await async_wait_recording_done(hass) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=four) + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, four, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(four).timestamp(), + "end": process_timestamp(four + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(15), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + ("attributes", "expected_mean", "expected_min", "expected_max"), + [ + (TEMPERATURE_SENSOR_ATTRIBUTES, 21.1864406779661, 10.0, 25.0), + (WIND_DIRECTION_ATTRIBUTES, 21.202479155239875, None, None), + ], +) async def test_compile_hourly_statistics_partially_unavailable( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + attributes: dict, + expected_mean: float, + expected_min: float | None, + expected_max: float | None, ) -> None: """Test compiling hourly statistics, with the sensor being partially unavailable.""" zero = get_start_time(dt_util.utcnow()) @@ -2393,7 +2712,7 @@ async def test_compile_hourly_statistics_partially_unavailable( # Wait for the sensor recorder platform to be added await async_recorder_block_till_done(hass) four, states = await async_record_states_partially_unavailable( - hass, zero, "sensor.test1", TEMPERATURE_SENSOR_ATTRIBUTES + hass, zero, "sensor.test1", attributes ) await async_wait_recording_done(hass) hist = history.get_significant_states( @@ -2409,9 +2728,9 @@ async def test_compile_hourly_statistics_partially_unavailable( { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), - "mean": pytest.approx(21.1864406779661), - "min": pytest.approx(10.0), - "max": pytest.approx(25.0), + "mean": pytest.approx(expected_mean), + "min": pytest.approx(expected_min), + "max": pytest.approx(expected_max), "last_reset": None, "state": None, "sum": None, @@ -2502,6 +2821,58 @@ async def test_compile_hourly_statistics_unavailable( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_unavailable_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics, with one sensor being unavailable for measurement_angle. + + sensor.test1 is unavailable and should not have statistics generated + sensor.test2 should have statistics generated + """ + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + four, states = await async_record_states_partially_unavailable( + hass, zero, "sensor.test1", WIND_DIRECTION_ATTRIBUTES + ) + with freeze_time(zero) as freezer: + _, _states = await async_record_states( + hass, + freezer, + zero, + "sensor.test2", + WIND_DIRECTION_ATTRIBUTES, + seq=WIND_DIRECTION_STATES_SEQ, + ) + await async_wait_recording_done(hass) + states = {**states, **_states} + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=four) + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, four, period="5minute") + assert stats == { + "sensor.test2": [ + { + "start": process_timestamp(four).timestamp(), + "end": process_timestamp(four + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(15), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + async def test_compile_hourly_statistics_fails( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -2530,59 +2901,267 @@ async def test_compile_hourly_statistics_fails( "statistic_type", ), [ - ("measurement", "area", "m²", "m²", "m²", "area", "mean"), - ("measurement", "area", "mi²", "mi²", "mi²", "area", "mean"), + ("measurement", "area", "m²", "m²", "m²", "area", StatisticMeanType.ARITHMETIC), + ( + "measurement", + "area", + "mi²", + "mi²", + "mi²", + "area", + StatisticMeanType.ARITHMETIC, + ), ("total", "area", "m²", "m²", "m²", "area", "sum"), ("total", "area", "mi²", "mi²", "mi²", "area", "sum"), - ("measurement", "battery", "%", "%", "%", "unitless", "mean"), - ("measurement", "battery", None, None, None, "unitless", "mean"), - ("measurement", "distance", "m", "m", "m", "distance", "mean"), - ("measurement", "distance", "mi", "mi", "mi", "distance", "mean"), + ( + "measurement", + "battery", + "%", + "%", + "%", + "unitless", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "battery", + None, + None, + None, + "unitless", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "distance", + "m", + "m", + "m", + "distance", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "distance", + "mi", + "mi", + "mi", + "distance", + StatisticMeanType.ARITHMETIC, + ), ("total", "distance", "m", "m", "m", "distance", "sum"), ("total", "distance", "mi", "mi", "mi", "distance", "sum"), ("total", "energy", "Wh", "Wh", "Wh", "energy", "sum"), ("total", "energy", "kWh", "kWh", "kWh", "energy", "sum"), - ("measurement", "energy", "Wh", "Wh", "Wh", "energy", "mean"), - ("measurement", "energy", "kWh", "kWh", "kWh", "energy", "mean"), - ("measurement", "humidity", "%", "%", "%", "unitless", "mean"), - ("measurement", "humidity", None, None, None, "unitless", "mean"), + ( + "measurement", + "energy", + "Wh", + "Wh", + "Wh", + "energy", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "energy", + "kWh", + "kWh", + "kWh", + "energy", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "humidity", + "%", + "%", + "%", + "unitless", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "humidity", + None, + None, + None, + "unitless", + StatisticMeanType.ARITHMETIC, + ), ("total", "monetary", "USD", "USD", "USD", None, "sum"), ("total", "monetary", "None", "None", "None", None, "sum"), ("total", "gas", "m³", "m³", "m³", "volume", "sum"), ("total", "gas", "ft³", "ft³", "ft³", "volume", "sum"), - ("measurement", "monetary", "USD", "USD", "USD", None, "mean"), - ("measurement", "monetary", "None", "None", "None", None, "mean"), - ("measurement", "gas", "m³", "m³", "m³", "volume", "mean"), - ("measurement", "gas", "ft³", "ft³", "ft³", "volume", "mean"), - ("measurement", "pressure", "Pa", "Pa", "Pa", "pressure", "mean"), - ("measurement", "pressure", "hPa", "hPa", "hPa", "pressure", "mean"), - ("measurement", "pressure", "mbar", "mbar", "mbar", "pressure", "mean"), - ("measurement", "pressure", "inHg", "inHg", "inHg", "pressure", "mean"), - ("measurement", "pressure", "psi", "psi", "psi", "pressure", "mean"), - ("measurement", "speed", "m/s", "m/s", "m/s", "speed", "mean"), - ("measurement", "speed", "mph", "mph", "mph", "speed", "mean"), - ("measurement", "temperature", "°C", "°C", "°C", "temperature", "mean"), - ("measurement", "temperature", "°F", "°F", "°F", "temperature", "mean"), - ("measurement", "volume", "m³", "m³", "m³", "volume", "mean"), - ("measurement", "volume", "ft³", "ft³", "ft³", "volume", "mean"), + ( + "measurement", + "monetary", + "USD", + "USD", + "USD", + None, + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "monetary", + "None", + "None", + "None", + None, + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "gas", + "m³", + "m³", + "m³", + "volume", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "gas", + "ft³", + "ft³", + "ft³", + "volume", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "Pa", + "Pa", + "Pa", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "hPa", + "hPa", + "hPa", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "mbar", + "mbar", + "mbar", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "inHg", + "inHg", + "inHg", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "psi", + "psi", + "psi", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "speed", + "m/s", + "m/s", + "m/s", + "speed", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "speed", + "mph", + "mph", + "mph", + "speed", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "temperature", + "°C", + "°C", + "°C", + "temperature", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "temperature", + "°F", + "°F", + "°F", + "temperature", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "volume", + "m³", + "m³", + "m³", + "volume", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "volume", + "ft³", + "ft³", + "ft³", + "volume", + StatisticMeanType.ARITHMETIC, + ), ("total", "volume", "m³", "m³", "m³", "volume", "sum"), ("total", "volume", "ft³", "ft³", "ft³", "volume", "sum"), - ("measurement", "weight", "g", "g", "g", "mass", "mean"), - ("measurement", "weight", "oz", "oz", "oz", "mass", "mean"), + ("measurement", "weight", "g", "g", "g", "mass", StatisticMeanType.ARITHMETIC), + ( + "measurement", + "weight", + "oz", + "oz", + "oz", + "mass", + StatisticMeanType.ARITHMETIC, + ), ("total", "weight", "g", "g", "g", "mass", "sum"), ("total", "weight", "oz", "oz", "oz", "mass", "sum"), + ( + SensorStateClass.MEASUREMENT_ANGLE, + SensorDeviceClass.WIND_DIRECTION, + DEGREE, + DEGREE, + DEGREE, + None, + StatisticMeanType.CIRCULAR, + ), ], ) async def test_list_statistic_ids( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - state_class, - device_class, - state_unit, - display_unit, - statistics_unit, - unit_class, - statistic_type, + state_class: str | SensorStateClass, + device_class: str | SensorDeviceClass, + state_unit: str, + display_unit: str, + statistics_unit: str, + unit_class: str | None, + statistic_type: str | StatisticMeanType, ) -> None: """Test listing future statistic ids.""" await async_setup_component(hass, "sensor", {}) @@ -2596,11 +3175,20 @@ async def test_list_statistic_ids( } hass.states.async_set("sensor.test1", 0, attributes=attributes) statistic_ids = await async_list_statistic_ids(hass) + mean_type = ( + statistic_type + if isinstance(statistic_type, StatisticMeanType) + else StatisticMeanType.NONE + ) + statistic_type = ( + statistic_type if not isinstance(statistic_type, StatisticMeanType) else "mean" + ) assert statistic_ids == [ { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": statistic_type == "mean", + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": statistic_type == "sum", "name": None, "source": "recorder", @@ -2608,6 +3196,7 @@ async def test_list_statistic_ids( "unit_class": unit_class, }, ] + for stat_type in ("mean", "sum", "dogs"): statistic_ids = await async_list_statistic_ids(hass, statistic_type=stat_type) if statistic_type == stat_type: @@ -2615,7 +3204,8 @@ async def test_list_statistic_ids( { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": statistic_type == "mean", + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": statistic_type == "sum", "name": None, "source": "recorder", @@ -2723,6 +3313,7 @@ async def test_compile_hourly_statistics_changing_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2758,6 +3349,7 @@ async def test_compile_hourly_statistics_changing_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2849,6 +3441,7 @@ async def test_compile_hourly_statistics_changing_units_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": "cats", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2931,6 +3524,7 @@ async def test_compile_hourly_statistics_changing_units_3( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2966,6 +3560,7 @@ async def test_compile_hourly_statistics_changing_units_3( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3048,6 +3643,7 @@ async def test_compile_hourly_statistics_convert_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit_1, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3095,6 +3691,7 @@ async def test_compile_hourly_statistics_convert_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit_2, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3211,6 +3808,7 @@ async def test_compile_hourly_statistics_equivalent_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3242,6 +3840,7 @@ async def test_compile_hourly_statistics_equivalent_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit2, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3333,6 +3932,7 @@ async def test_compile_hourly_statistics_equivalent_units_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3417,6 +4017,7 @@ async def test_compile_hourly_statistics_changing_device_class_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3466,6 +4067,7 @@ async def test_compile_hourly_statistics_changing_device_class_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3525,6 +4127,7 @@ async def test_compile_hourly_statistics_changing_device_class_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3629,6 +4232,7 @@ async def test_compile_hourly_statistics_changing_device_class_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3678,6 +4282,7 @@ async def test_compile_hourly_statistics_changing_device_class_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3717,15 +4322,13 @@ async def test_compile_hourly_statistics_changing_device_class_2( ( "device_class", "state_unit", - "display_unit", - "statistics_unit", "unit_class", "mean", "min", "max", ), [ - (None, None, None, None, "unitless", 13.050847, -10, 30), + (None, None, "unitless", 13.050847, -10, 30), ], ) async def test_compile_hourly_statistics_changing_state_class( @@ -3733,8 +4336,6 @@ async def test_compile_hourly_statistics_changing_state_class( caplog: pytest.LogCaptureFixture, device_class, state_unit, - display_unit, - statistics_unit, unit_class, mean, min, @@ -3770,6 +4371,7 @@ async def test_compile_hourly_statistics_changing_state_class( "statistic_id": "sensor.test1", "display_unit_of_measurement": None, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3783,6 +4385,7 @@ async def test_compile_hourly_statistics_changing_state_class( 1, { "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3812,6 +4415,7 @@ async def test_compile_hourly_statistics_changing_state_class( "statistic_id": "sensor.test1", "display_unit_of_measurement": None, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -3825,6 +4429,7 @@ async def test_compile_hourly_statistics_changing_state_class( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -3890,10 +4495,11 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "unit_of_measurement": "EUR", } + durations = [50, 200, 45] + def _weighted_average(seq, i, last_state): total = 0 duration = 0 - durations = [50, 200, 45] if i > 0: total += last_state * 5 duration += 5 @@ -3902,6 +4508,20 @@ async def test_compile_statistics_hourly_daily_monthly_summary( duration += dur return total / duration + def _weighted_circular_mean( + values: Iterable[tuple[float, float]], + ) -> tuple[float, float]: + sin_sum = 0 + cos_sum = 0 + for x, weight in values: + sin_sum += math.sin(x * DEG_TO_RAD) * weight + cos_sum += math.cos(x * DEG_TO_RAD) * weight + + return ( + (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360, + math.sqrt(sin_sum**2 + cos_sum**2), + ) + def _min(seq, last_state): if last_state is None: return min(seq) @@ -3923,17 +4543,24 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "sensor.test2": [], "sensor.test3": [], "sensor.test4": [], + "sensor.test5": [], } expected_minima = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} expected_maxima = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} - expected_averages = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} + expected_means = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test5": [], + } expected_states = {"sensor.test4": []} expected_sums = {"sensor.test4": []} - last_states = { + last_states: dict[str, float | None] = { "sensor.test1": None, "sensor.test2": None, "sensor.test3": None, "sensor.test4": None, + "sensor.test5": None, } start = zero for i in range(24): @@ -3946,7 +4573,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( last_state = last_states["sensor.test1"] expected_minima["sensor.test1"].append(_min(seq, last_state)) expected_maxima["sensor.test1"].append(_max(seq, last_state)) - expected_averages["sensor.test1"].append(_weighted_average(seq, i, last_state)) + expected_means["sensor.test1"].append(_weighted_average(seq, i, last_state)) last_states["sensor.test1"] = seq[-1] # test2 values change: min/max at the last state seq = [-10 * (i + 1), 15 * (i + 1), 30 * (i + 1)] @@ -3957,7 +4584,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( last_state = last_states["sensor.test2"] expected_minima["sensor.test2"].append(_min(seq, last_state)) expected_maxima["sensor.test2"].append(_max(seq, last_state)) - expected_averages["sensor.test2"].append(_weighted_average(seq, i, last_state)) + expected_means["sensor.test2"].append(_weighted_average(seq, i, last_state)) last_states["sensor.test2"] = seq[-1] # test3 values change: min/max at the first state seq = [-10 * (23 - i + 1), 15 * (23 - i + 1), 30 * (23 - i + 1)] @@ -3968,7 +4595,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( last_state = last_states["sensor.test3"] expected_minima["sensor.test3"].append(_min(seq, last_state)) expected_maxima["sensor.test3"].append(_max(seq, last_state)) - expected_averages["sensor.test3"].append(_weighted_average(seq, i, last_state)) + expected_means["sensor.test3"].append(_weighted_average(seq, i, last_state)) last_states["sensor.test3"] = seq[-1] # test4 values grow seq = [i, i + 0.5, i + 0.75] @@ -3991,6 +4618,18 @@ async def test_compile_statistics_hourly_daily_monthly_summary( ) last_states["sensor.test4"] = seq[-1] + # test5 circular mean + seq = [350 - i, 0 + (i / 2.0), 15 + i] + four, _states = await async_record_states( + hass, freezer, start, "sensor.test5", WIND_DIRECTION_ATTRIBUTES, seq + ) + states["sensor.test5"] += _states["sensor.test5"] + values = [(seq, durations[j]) for j, seq in enumerate(seq)] + if (state := last_states["sensor.test5"]) is not None: + values.append((state, 5)) + expected_means["sensor.test5"].append(_weighted_circular_mean(values)) + last_states["sensor.test5"] = seq[-1] + start += timedelta(minutes=5) await async_wait_recording_done(hass) hist = history.get_significant_states( @@ -4016,6 +4655,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test1", "display_unit_of_measurement": "%", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -4026,6 +4666,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test2", "display_unit_of_measurement": "%", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -4036,6 +4677,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test3", "display_unit_of_measurement": "%", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -4046,12 +4688,24 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test4", "display_unit_of_measurement": "EUR", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", "statistics_unit_of_measurement": "EUR", "unit_class": None, }, + { + "statistic_id": "sensor.test5", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + }, ] # Adjust the inserted statistics @@ -4070,19 +4724,21 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "sensor.test2": [], "sensor.test3": [], "sensor.test4": [], + "sensor.test5": [], } start = zero end = zero + timedelta(minutes=5) for i in range(24): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", + for entity_id, mean_extractor in ( + ("sensor.test1", lambda x: x), + ("sensor.test2", lambda x: x), + ("sensor.test3", lambda x: x), + ("sensor.test4", lambda x: x), + ("sensor.test5", lambda x: x[0]), ): expected_average = ( - expected_averages[entity_id][i] - if entity_id in expected_averages + mean_extractor(expected_means[entity_id][i]) + if entity_id in expected_means else None ) expected_minimum = ( @@ -4113,176 +4769,78 @@ async def test_compile_statistics_hourly_daily_monthly_summary( end += timedelta(minutes=5) assert stats == expected_stats - stats = statistics_during_period(hass, zero, period="hour") - expected_stats = { - "sensor.test1": [], - "sensor.test2": [], - "sensor.test3": [], - "sensor.test4": [], - } - start = zero - end = zero + timedelta(hours=1) - for i in range(2): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", - ): - expected_average = ( - mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_averages - else None - ) - expected_minimum = ( - min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_minima - else None - ) - expected_maximum = ( - max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_maxima - else None - ) - expected_state = ( - expected_states[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_states - else None - ) - expected_sum = ( - expected_sums[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_sums - else None - ) - expected_stats[entity_id].append( - { - "start": process_timestamp(start).timestamp(), - "end": process_timestamp(end).timestamp(), - "mean": pytest.approx(expected_average), - "min": pytest.approx(expected_minimum), - "max": pytest.approx(expected_maximum), - "last_reset": None, - "state": expected_state, - "sum": expected_sum, - } - ) - start += timedelta(hours=1) - end += timedelta(hours=1) - assert stats == expected_stats + def verify_stats( + period: Literal["hour", "day", "week", "month"], + start: datetime, + next_datetime: Callable[[datetime], datetime], + ) -> None: + stats = statistics_during_period(hass, zero, period=period) + expected_stats = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test4": [], + "sensor.test5": [], + } + end = next_datetime(start) + for i in range(2): + for entity_id, mean_fn in ( + ("sensor.test1", mean), + ("sensor.test2", mean), + ("sensor.test3", mean), + ("sensor.test4", mean), + ("sensor.test5", lambda x: _weighted_circular_mean(x)[0]), + ): + expected_average = ( + mean_fn(expected_means[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_means + else None + ) + expected_minimum = ( + min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_minima + else None + ) + expected_maximum = ( + max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_maxima + else None + ) + expected_state = ( + expected_states[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_states + else None + ) + expected_sum = ( + expected_sums[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_sums + else None + ) + expected_stats[entity_id].append( + { + "start": process_timestamp(start).timestamp(), + "end": process_timestamp(end).timestamp(), + "mean": pytest.approx(expected_average), + "min": pytest.approx(expected_minimum), + "max": pytest.approx(expected_maximum), + "last_reset": None, + "state": expected_state, + "sum": expected_sum, + } + ) + start = next_datetime(start) + end = next_datetime(end) + assert stats == expected_stats + + verify_stats("hour", zero, lambda v: v + timedelta(hours=1)) - stats = statistics_during_period(hass, zero, period="day") - expected_stats = { - "sensor.test1": [], - "sensor.test2": [], - "sensor.test3": [], - "sensor.test4": [], - } start = dt_util.parse_datetime("2021-08-31T06:00:00+00:00") - end = start + timedelta(days=1) - for i in range(2): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", - ): - expected_average = ( - mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_averages - else None - ) - expected_minimum = ( - min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_minima - else None - ) - expected_maximum = ( - max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_maxima - else None - ) - expected_state = ( - expected_states[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_states - else None - ) - expected_sum = ( - expected_sums[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_sums - else None - ) - expected_stats[entity_id].append( - { - "start": process_timestamp(start).timestamp(), - "end": process_timestamp(end).timestamp(), - "mean": pytest.approx(expected_average), - "min": pytest.approx(expected_minimum), - "max": pytest.approx(expected_maximum), - "last_reset": None, - "state": expected_state, - "sum": expected_sum, - } - ) - start += timedelta(days=1) - end += timedelta(days=1) - assert stats == expected_stats + assert start + verify_stats("day", start, lambda v: v + timedelta(days=1)) - stats = statistics_during_period(hass, zero, period="month") - expected_stats = { - "sensor.test1": [], - "sensor.test2": [], - "sensor.test3": [], - "sensor.test4": [], - } start = dt_util.parse_datetime("2021-08-01T06:00:00+00:00") - end = dt_util.parse_datetime("2021-09-01T06:00:00+00:00") - for i in range(2): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", - ): - expected_average = ( - mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_averages - else None - ) - expected_minimum = ( - min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_minima - else None - ) - expected_maximum = ( - max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_maxima - else None - ) - expected_state = ( - expected_states[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_states - else None - ) - expected_sum = ( - expected_sums[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_sums - else None - ) - expected_stats[entity_id].append( - { - "start": process_timestamp(start).timestamp(), - "end": process_timestamp(end).timestamp(), - "mean": pytest.approx(expected_average), - "min": pytest.approx(expected_minimum), - "max": pytest.approx(expected_maximum), - "last_reset": None, - "state": expected_state, - "sum": expected_sum, - } - ) - start = (start + timedelta(days=31)).replace(day=1) - end = (end + timedelta(days=31)).replace(day=1) - assert stats == expected_stats + assert start + verify_stats("month", start, lambda v: (v + timedelta(days=31)).replace(day=1)) assert "Error while processing event StatisticsTask" not in caplog.text @@ -4428,11 +4986,11 @@ async def test_validate_unit_change_convertible( "statistic_id": "sensor.test", "supported_unit": supported_unit, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -4653,11 +5211,11 @@ async def test_validate_statistics_unit_change_no_device_class( "statistic_id": "sensor.test", "supported_unit": supported_unit, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -4769,11 +5327,11 @@ async def test_validate_statistics_state_class_removed( "sensor.test": [ { "data": {"statistic_id": "sensor.test"}, - "type": "state_class_removed", + "type": STATE_CLASS_REMOVED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"state_class_removed"}) + await assert_validation_result(hass, client, expected, {STATE_CLASS_REMOVED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -4837,11 +5395,11 @@ async def test_validate_statistics_state_class_removed_issue_cleaned_up( "sensor.test": [ { "data": {"statistic_id": "sensor.test"}, - "type": "state_class_removed", + "type": STATE_CLASS_REMOVED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"state_class_removed"}) + await assert_validation_result(hass, client, expected, {STATE_CLASS_REMOVED_ISSUE}) # Remove the statistics - empty response get_instance(hass).async_clear_statistics(["sensor.test"]) @@ -5086,11 +5644,11 @@ async def test_validate_statistics_unit_change_no_conversion( "statistic_id": "sensor.test", "supported_unit": unit1, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -5267,11 +5825,11 @@ async def test_validate_statistics_unit_change_equivalent_units_2( "statistic_id": "sensor.test", "supported_unit": supported_unit, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Run statistics one hour later, metadata will not be updated await async_recorder_block_till_done(hass) @@ -5280,7 +5838,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2( await assert_statistic_ids( hass, [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] ) - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) async def test_validate_statistics_other_domain( @@ -5369,7 +5927,7 @@ async def test_update_statistics_issues( now = await one_hour_stats(now) expected = { "state_class_removed_sensor.test": { - "issue_type": "state_class_removed", + "issue_type": STATE_CLASS_REMOVED_ISSUE, "statistic_id": "sensor.test", } } @@ -5573,8 +6131,9 @@ async def test_clean_up_repairs( create_issue("test", "test_issue", None) create_issue(DOMAIN, "test_issue_1", None) create_issue(DOMAIN, "test_issue_2", {"issue_type": "another_issue"}) - create_issue(DOMAIN, "test_issue_3", {"issue_type": "state_class_removed"}) - create_issue(DOMAIN, "test_issue_4", {"issue_type": "units_changed"}) + create_issue(DOMAIN, "test_issue_3", {"issue_type": STATE_CLASS_REMOVED_ISSUE}) + create_issue(DOMAIN, "test_issue_4", {"issue_type": UNITS_CHANGED_ISSUE}) + create_issue(DOMAIN, "test_issue_5", {"issue_type": MEAN_TYPE_CHANGED_ISSUE}) # Check the issues assert set(issue_registry.issues) == { @@ -5583,6 +6142,7 @@ async def test_clean_up_repairs( ("sensor", "test_issue_2"), ("sensor", "test_issue_3"), ("sensor", "test_issue_4"), + ("sensor", "test_issue_5"), } # Request update of issues @@ -5596,3 +6156,140 @@ async def test_clean_up_repairs( ("sensor", "test_issue_1"), ("sensor", "test_issue_2"), } + + +async def test_validate_statistics_mean_type_changed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validate_statistics. + + This tests a validation issue is created when a the mean type is changed. + """ + now = get_start_time(dt_util.utcnow()) + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(hass, client, {}, {}) + + # No statistics, original unit - empty response + hass.states.async_set( + "sensor.wind_direction", + 10, + attributes=WIND_DIRECTION_ATTRIBUTES, + timestamp=now.timestamp(), + ) + await assert_validation_result(hass, client, {}, {}) + + # Run statistics + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now) + await async_recorder_block_till_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.wind_direction", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + + expected_log_entry = ( + "homeassistant.components.sensor.recorder", + logging.WARNING, + ( + "The statistics mean algorithm for sensor.wind_direction have changed from" + " CIRCULAR to ARITHMETIC. Generation of long term statistics will be " + "suppressed unless it changes back or go to " + "https://my.home-assistant.io/redirect/developer_statistics " + "to delete the old statistics" + ), + ) + # Valid stats, no log entry + assert expected_log_entry not in caplog.record_tuples + + # State class changed + hass.states.async_set( + "sensor.wind_direction", + 5, + attributes={ + **WIND_DIRECTION_ATTRIBUTES, + "state_class": SensorStateClass.MEASUREMENT, + }, + timestamp=now.timestamp(), + ) + expected = { + "sensor.wind_direction": [ + { + "data": { + "statistic_id": "sensor.wind_direction", + "metadata_mean_type": StatisticMeanType.CIRCULAR, + "state_mean_type": StatisticMeanType.ARITHMETIC, + }, + "type": MEAN_TYPE_CHANGED_ISSUE, + } + ], + } + await assert_validation_result(hass, client, expected, {MEAN_TYPE_CHANGED_ISSUE}) + + # Run statistics one hour later, metadata will not be updated + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now + timedelta(hours=1)) + await async_recorder_block_till_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.wind_direction", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + await assert_validation_result(hass, client, expected, {MEAN_TYPE_CHANGED_ISSUE}) + assert expected_log_entry in caplog.record_tuples + + # State class changed back + hass.states.async_set( + "sensor.wind_direction", + 350, + attributes=WIND_DIRECTION_ATTRIBUTES, + timestamp=now.timestamp(), + ) + await assert_validation_result(hass, client, {}, {}) + + # Run statistics + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now) + await async_recorder_block_till_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.wind_direction", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + + # Issue should be resolved + await assert_validation_result(hass, client, {}, {}) diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index ddece280d8a..ec2d3d2c829 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -143,20 +143,6 @@ def get_entity( ) -def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: - """Return entity state.""" - entity = hass.states.get(entity_id) - assert entity - return entity.state - - -def get_entity_attribute(hass: HomeAssistant, entity_id: str, attribute: str) -> str: - """Return entity attribute.""" - entity = hass.states.get(entity_id) - assert entity - return entity.attributes[attribute] - - def register_device( device_registry: DeviceRegistry, config_entry: ConfigEntry ) -> DeviceEntry: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8030df6e473..8f8255235be 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -513,6 +513,9 @@ def _mock_blu_rtv_device(version: str | None = None): firmware_version="some fw string", initialized=True, connected=True, + script_getcode=AsyncMock( + side_effect=lambda script_id: {"data": MOCK_SCRIPTS[script_id - 1]} + ), xmod_info={}, ) type(device).name = PropertyMock(return_value="Test name") diff --git a/tests/components/shelly/snapshots/test_button.ambr b/tests/components/shelly/snapshots/test_button.ambr new file mode 100644 index 00000000000..f5a38f1b847 --- /dev/null +++ b/tests/components/shelly/snapshots/test_button.ambr @@ -0,0 +1,96 @@ +# serializer version: 1 +# name: test_rpc_blu_trv_button[button.trv_name_calibrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.trv_name_calibrate', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TRV-Name Calibrate', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibrate', + 'unique_id': 'f8:44:77:25:f0:dd_calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_blu_trv_button[button.trv_name_calibrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name Calibrate', + }), + 'context': , + 'entity_id': 'button.trv_name_calibrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_rpc_button[button.test_name_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_name_reboot', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Test name Reboot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_button[button.test_name_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test name Reboot', + }), + 'context': , + 'entity_id': 'button.test_name_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 1e7c54320e8..ea3a7d5f3d2 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -39,15 +39,16 @@ async def test_block_binary_sensor( entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_channel_1_overpowering" await init_integration(hass, 1) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "overpower", 1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-overpower" @@ -61,19 +62,18 @@ async def test_block_binary_sensor_extra_state_attr( entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_gas" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes.get("detected") == "mild" monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "gas", "none") mock_block_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes.get("detected") == "none" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-gas" @@ -89,15 +89,16 @@ async def test_block_rest_binary_sensor( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setitem(mock_block_device.status["cloud"], "connected", True) await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cloud" @@ -115,20 +116,22 @@ async def test_block_rest_binary_sensor_connected_battery_devices( monkeypatch.setitem(mock_block_device.settings["coiot"], "update_period", 3600) await init_integration(hass, 1, model=MODEL_MOTION) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setitem(mock_block_device.status["cloud"], "connected", True) # Verify no update on fast intervals await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF # Verify update on slow intervals await mock_rest_update(hass, freezer, seconds=UPDATE_PERIOD_MULTIPLIER * 3600) - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cloud" @@ -149,15 +152,16 @@ async def test_block_sleeping_binary_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "motion", 1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-motion" @@ -183,14 +187,16 @@ async def test_block_restored_sleeping_binary_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_block_restored_sleeping_binary_sensor_no_last_state( @@ -214,14 +220,16 @@ async def test_block_restored_sleeping_binary_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_binary_sensor( @@ -234,17 +242,18 @@ async def test_rpc_binary_sensor( entity_id = f"{BINARY_SENSOR_DOMAIN}.test_cover_0_overpowering" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF mutate_rpc_device_status( monkeypatch, mock_rpc_device, "cover:0", "errors", "overpower" ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cover:0-overpower" @@ -290,20 +299,22 @@ async def test_rpc_sleeping_binary_sensor( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cloud", "connected", True) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON - - # test external power sensor - state = hass.states.get("binary_sensor.test_name_external_power") - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get("binary_sensor.test_name_external_power") - assert entry + # test external power sensor + assert (state := hass.states.get("binary_sensor.test_name_external_power")) + assert state.state == STATE_ON + + assert ( + entry := entity_registry.async_get("binary_sensor.test_name_external_power") + ) assert entry.unique_id == "123456789ABC-devicepower:0-external_power" @@ -331,14 +342,16 @@ async def test_rpc_restored_sleeping_binary_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_restored_sleeping_binary_sensor_no_last_state( @@ -364,7 +377,8 @@ async def test_rpc_restored_sleeping_binary_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) @@ -375,7 +389,8 @@ async def test_rpc_restored_sleeping_binary_sensor_no_last_state( mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF @pytest.mark.parametrize( @@ -407,17 +422,17 @@ async def test_rpc_device_virtual_binary_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-boolean:203-boolean" monkeypatch.setitem(mock_rpc_device.status["boolean:203"], "value", False) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_OFF + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( @@ -450,8 +465,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_binary_sensor_when_orphaned( @@ -475,8 +489,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_blu_trv_binary_sensor_entity( diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 14349411670..2057076d18b 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -2,12 +2,17 @@ from unittest.mock import Mock +from aioshelly.const import MODEL_BLU_GATEWAY_G3 +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.shelly.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_registry import EntityRegistry from . import init_integration @@ -22,10 +27,10 @@ async def test_block_button( entity_id = "button.test_name_reboot" # reboot button - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC_reboot" await hass.services.async_call( @@ -38,7 +43,10 @@ async def test_block_button( async def test_rpc_button( - hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test rpc device OTA button.""" await init_integration(hass, 2) @@ -46,11 +54,11 @@ async def test_rpc_button( entity_id = "button.test_name_reboot" # reboot button - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == "123456789ABC_reboot" + assert (entry := entity_registry.async_get(entity_id)) + assert entry == snapshot(name=f"{entity_id}-entry") await hass.services.async_call( BUTTON_DOMAIN, @@ -61,6 +69,68 @@ async def test_rpc_button( assert mock_rpc_device.trigger_reboot.call_count == 1 +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for button.test_name_reboot of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for button.test_name_reboot of Test name", + ), + ], +) +async def test_rpc_button_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + exception: Exception, + error: str, +) -> None: + """Test RPC button with exception.""" + await init_integration(hass, 2) + + mock_rpc_device.trigger_reboot.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_reboot"}, + blocking=True, + ) + + +async def test_rpc_button_reauth_error( + hass: HomeAssistant, mock_rpc_device: Mock +) -> None: + """Test rpc device OTA button with authentication error.""" + entry = await init_integration(hass, 2) + + mock_rpc_device.trigger_reboot.side_effect = InvalidAuthError + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_reboot"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id + + @pytest.mark.parametrize( ("gen", "old_unique_id", "new_unique_id", "migration"), [ @@ -104,3 +174,107 @@ async def test_migrate_unique_id( bool("Migrating unique_id for button.test_name_reboot" in caplog.text) == migration ) + + +async def test_rpc_blu_trv_button( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test RPC BLU TRV button.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + entity_id = "button.trv_name_calibrate" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert mock_blu_trv.trigger_blu_trv_calibration.call_count == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for button.trv_name_calibrate of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for button.trv_name_calibrate of Test name", + ), + ], +) +async def test_rpc_blu_trv_button_exc( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, + error: str, +) -> None: + """Test RPC BLU TRV button with exception.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.trigger_blu_trv_calibration.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.trv_name_calibrate"}, + blocking=True, + ) + + +async def test_rpc_blu_trv_button_auth_error( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC BLU TRV button with authentication error.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.trigger_blu_trv_calibration.side_effect = InvalidAuthError + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.trv_name_calibrate"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index fcfed090a66..b2135fb38af 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, Mock, PropertyMock from aioshelly.const import ( BLU_TRV_IDENTIFIER, + BLU_TRV_TIMEOUT, MODEL_BLU_GATEWAY_G3, MODEL_VALVE, MODEL_WALL_DISPLAY, @@ -27,7 +28,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN +from homeassistant.components.shelly.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -43,13 +44,7 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import ( - MOCK_MAC, - get_entity_attribute, - init_integration, - register_device, - register_entity, -) +from . import MOCK_MAC, init_integration, register_device, register_entity from .conftest import MOCK_STATUS_COAP from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data @@ -85,11 +80,9 @@ async def test_climate_hvac_mode( await hass.async_block_till_done(wait_background_tasks=True) # Test initial hvac mode - off - state = hass.states.get(ENTITY_ID) - assert state == snapshot(name=f"{ENTITY_ID}-state") + assert hass.states.get(ENTITY_ID) == snapshot(name=f"{ENTITY_ID}-state") - entry = entity_registry.async_get(ENTITY_ID) - assert entry == snapshot(name=f"{ENTITY_ID}-entry") + assert entity_registry.async_get(ENTITY_ID) == snapshot(name=f"{ENTITY_ID}-entry") # Test set hvac mode heat await hass.services.async_call( @@ -104,7 +97,8 @@ async def test_climate_hvac_mode( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 20.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + + assert (state := hass.states.get(ENTITY_ID)) assert state.state == HVACMode.HEAT # Test set hvac mode off @@ -121,13 +115,13 @@ async def test_climate_hvac_mode( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 4.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == HVACMode.OFF # Test unavailable on error monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 1) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_UNAVAILABLE @@ -144,7 +138,7 @@ async def test_climate_set_temperature( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == HVACMode.OFF assert state.attributes[ATTR_TEMPERATURE] == 4 @@ -198,7 +192,7 @@ async def test_climate_set_preset_mode( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE # Test set Profile2 @@ -216,7 +210,7 @@ async def test_climate_set_preset_mode( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", 2) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.attributes[ATTR_PRESET_MODE] == "Profile2" # Set preset to none @@ -235,7 +229,7 @@ async def test_climate_set_preset_mode( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", 0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE @@ -270,23 +264,26 @@ async def test_block_restored_climate( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 4.0 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 4.0 # Partial update, should not change state mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 4.0 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 4.0 # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 4.0 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 4.0 # Test set hvac mode heat, target temp should be set to last target temp (22) await hass.services.async_call( @@ -301,9 +298,10 @@ async def test_block_restored_climate( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 22.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.HEAT - assert hass.states.get(entity_id).attributes.get("temperature") == 22.0 + assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 async def test_block_restored_climate_us_customary( @@ -338,17 +336,19 @@ async def test_block_restored_climate_us_customary( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 39 - assert hass.states.get(entity_id).attributes.get("current_temperature") == 67 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 39 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 67 # Partial update, should not change state mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 39 - assert hass.states.get(entity_id).attributes.get("current_temperature") == 67 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 39 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 67 # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) @@ -357,9 +357,10 @@ async def test_block_restored_climate_us_customary( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 39 - assert hass.states.get(entity_id).attributes.get("current_temperature") == 65 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 39 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 65 # Test set hvac mode heat, target temp should be set to last target temp (10.0/50) await hass.services.async_call( @@ -374,9 +375,10 @@ async def test_block_restored_climate_us_customary( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 10.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.HEAT - assert hass.states.get(entity_id).attributes.get("temperature") == 50 + assert state.attributes.get(ATTR_TEMPERATURE) == 50 async def test_block_restored_climate_unavailable( @@ -404,7 +406,8 @@ async def test_block_restored_climate_unavailable( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF async def test_block_restored_climate_set_preset_before_online( @@ -432,7 +435,8 @@ async def test_block_restored_climate_set_preset_before_online( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.HEAT + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.HEAT with pytest.raises(ServiceValidationError): await hass.services.async_call( @@ -461,7 +465,10 @@ async def test_block_set_mode_connection_error( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while calling action for climate.test_name of Test name", + ): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -611,16 +618,14 @@ async def test_rpc_climate_hvac_mode( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert (state := hass.states.get(entity_id)) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "output", False) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE assert state.attributes[ATTR_CURRENT_HUMIDITY] == 44.4 @@ -636,7 +641,7 @@ async def test_rpc_climate_hvac_mode( mock_rpc_device.call_rpc.assert_called_once_with( "Thermostat.SetConfig", {"config": {"id": 0, "enable": False}} ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.OFF @@ -654,15 +659,14 @@ async def test_rpc_climate_without_humidity( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.HEAT assert state.attributes[ATTR_TEMPERATURE] == 23 assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING assert ATTR_CURRENT_HUMIDITY not in state.attributes - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-thermostat:0" @@ -674,7 +678,7 @@ async def test_rpc_climate_set_temperature( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_TEMPERATURE] == 23 monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "target_C", 28) @@ -689,7 +693,7 @@ async def test_rpc_climate_set_temperature( mock_rpc_device.call_rpc.assert_called_once_with( "Thermostat.SetConfig", {"config": {"id": 0, "target_C": 28}} ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_TEMPERATURE] == 28 @@ -704,7 +708,7 @@ async def test_rpc_climate_hvac_mode_cool( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.COOL assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING @@ -753,19 +757,16 @@ async def test_wall_display_thermostat_mode_external_actuator( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) # the switch entity should be created - state = hass.states.get(switch_entity_id) - assert state + assert (state := hass.states.get(switch_entity_id)) assert state.state == STATE_ON assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 # the climate entity should be created - state = hass.states.get(climate_entity_id) - assert state + assert (state := hass.states.get(climate_entity_id)) assert state.state == HVACMode.HEAT assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 - entry = entity_registry.async_get(climate_entity_id) - assert entry + assert (entry := entity_registry.async_get(climate_entity_id)) assert entry.unique_id == "123456789ABC-thermostat:0" @@ -783,13 +784,9 @@ async def test_blu_trv_climate_set_temperature( await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert (state := hass.states.get(entity_id)) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") - - assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") monkeypatch.setitem( mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "target_C", 28 @@ -812,7 +809,8 @@ async def test_blu_trv_climate_set_temperature( BLU_TRV_TIMEOUT, ) - assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 28 + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_TEMPERATURE] == 28 async def test_blu_trv_climate_disabled( @@ -827,14 +825,16 @@ async def test_blu_trv_climate_disabled( await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) - assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_TEMPERATURE] == 17.1 monkeypatch.setitem( mock_blu_trv.config[f"{BLU_TRV_IDENTIFIER}:200"], "enable", False ) mock_blu_trv.mock_update() - assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) is None + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_TEMPERATURE] is None async def test_blu_trv_climate_hvac_action( @@ -849,9 +849,11 @@ async def test_blu_trv_climate_hvac_action( await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) - assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.IDLE + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE monkeypatch.setitem(mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "pos", 10) mock_blu_trv.mock_update() - assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.HEATING + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 0b2d355cfd8..fffffc21cae 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -24,6 +24,7 @@ from homeassistant.components.shelly.const import ( BLEScannerMode, ) from homeassistant.components.shelly.coordinator import ENTRY_RELOAD_COOLDOWN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_MODEL, @@ -744,6 +745,38 @@ async def test_zeroconf_sleeping_device_error(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_connect" +async def test_options_flow_abort_setup_retry( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test ble options abort if device is in setup retry.""" + monkeypatch.setattr( + mock_rpc_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + ) + entry = await init_integration(hass, 2) + + assert entry.state is ConfigEntryState.SETUP_RETRY + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_options_flow_abort_no_scripts_support( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test ble options abort if device does not support scripts.""" + monkeypatch.setattr( + mock_rpc_device, "supports_scripts", AsyncMock(return_value=False) + ) + entry = await init_integration(hass, 2) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_scripts_support" + + async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -1080,7 +1113,7 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED + assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.DISABLED result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM @@ -1096,7 +1129,7 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.ACTIVE + assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.ACTIVE result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM @@ -1112,7 +1145,7 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.PASSIVE + assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.PASSIVE await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 55a1d8958cd..f89bec8853a 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -32,7 +32,6 @@ from homeassistant.helpers import device_registry as dr, issue_registry as ir from . import ( MOCK_MAC, - get_entity_state, init_integration, inject_rpc_device_event, mock_polling_rpc_update, @@ -72,7 +71,7 @@ async def test_block_reload_on_cfg_change( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Generate config change from switch to light monkeypatch.setitem( @@ -82,7 +81,7 @@ async def test_block_reload_on_cfg_change( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) @@ -114,14 +113,14 @@ async def test_block_no_reload_on_bulb_changes( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Test no reload on effect change monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "effect", 1) @@ -129,14 +128,14 @@ async def test_block_no_reload_on_bulb_changes( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") async def test_block_polling_auth_error( @@ -245,14 +244,16 @@ async def test_block_polling_connection_error( ) await init_integration(hass, 1) - assert get_entity_state(hass, "switch.test_name_channel_1") == STATE_ON + assert (state := hass.states.get("switch.test_name_channel_1")) + assert state.state == STATE_ON # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert get_entity_state(hass, "switch.test_name_channel_1") == STATE_UNAVAILABLE + assert (state := hass.states.get("switch.test_name_channel_1")) + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("exc", [DeviceConnectionError, MacAddressMismatchError]) @@ -270,12 +271,14 @@ async def test_block_rest_update_connection_error( await init_integration(hass, 1) await mock_rest_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON monkeypatch.setattr(mock_block_device, "update_shelly", AsyncMock(side_effect=exc)) await mock_rest_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_block_sleeping_device_no_periodic_updates( @@ -297,14 +300,16 @@ async def test_block_sleeping_device_no_periodic_updates( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 3600)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_block_device_push_updates_failure( @@ -416,7 +421,7 @@ async def test_rpc_reload_on_cfg_change( ) await hass.async_block_till_done() - assert hass.states.get("switch.test_switch_0") is not None + assert hass.states.get("switch.test_switch_0") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) @@ -596,14 +601,16 @@ async def test_rpc_sleeping_device_no_periodic_updates( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 1000)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) is STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_sleeping_device_firmware_unsupported( @@ -716,7 +723,8 @@ async def test_rpc_reconnect_error( monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setattr(mock_rpc_device, "initialize", AsyncMock(side_effect=exc)) @@ -726,7 +734,8 @@ async def test_rpc_reconnect_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_UNAVAILABLE async def test_rpc_error_running_connected_events( @@ -748,14 +757,17 @@ async def test_rpc_error_running_connected_events( ) assert "Error running connected events for device" in caplog.text - assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE + + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_UNAVAILABLE # Move time to generate reconnect without error freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON async def test_rpc_polling_connection_error( @@ -776,11 +788,13 @@ async def test_rpc_polling_connection_error( ), ) - assert get_entity_state(hass, entity_id) == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" await mock_polling_rpc_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_polling_disconnected( @@ -795,11 +809,13 @@ async def test_rpc_polling_disconnected( monkeypatch.setattr(mock_rpc_device, "connected", False) - assert get_entity_state(hass, entity_id) == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" await mock_polling_rpc_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_update_entry_fw_ver( @@ -837,12 +853,17 @@ async def test_rpc_update_entry_fw_ver( assert device.sw_version == "99.0.0" +@pytest.mark.parametrize(("supports_scripts"), [True, False]) async def test_rpc_runs_connected_events_when_initialized( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, + supports_scripts: bool, ) -> None: """Test RPC runs connected events when initialized.""" + monkeypatch.setattr( + mock_rpc_device, "supports_scripts", AsyncMock(return_value=supports_scripts) + ) monkeypatch.setattr(mock_rpc_device, "initialized", False) await init_integration(hass, 2) @@ -853,8 +874,9 @@ async def test_rpc_runs_connected_events_when_initialized( mock_rpc_device.mock_initialized() await hass.async_block_till_done() - # BLE script list is called during connected events - assert call.script_list() in mock_rpc_device.mock_calls + assert call.supports_scripts() in mock_rpc_device.mock_calls + # BLE script list is called during connected events if device supports scripts + assert bool(call.script_list() in mock_rpc_device.mock_calls) == supports_scripts async def test_rpc_sleeping_device_unload_ignore_ble_scanner( @@ -903,7 +925,8 @@ async def test_block_sleeping_device_connection_error( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online event with connection error monkeypatch.setattr( @@ -917,7 +940,8 @@ async def test_block_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Error connecting to Shelly device" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Move time to generate sleep period update freezer.tick(timedelta(seconds=sleep_period * UPDATE_PERIOD_MULTIPLIER)) @@ -925,7 +949,8 @@ async def test_block_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Sleeping device did not update" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_sleeping_device_connection_error( @@ -954,7 +979,8 @@ async def test_rpc_sleeping_device_connection_error( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online event with connection error monkeypatch.setattr( @@ -968,7 +994,8 @@ async def test_rpc_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Error connecting to Shelly device" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Move time to generate sleep period update freezer.tick(timedelta(seconds=sleep_period * UPDATE_PERIOD_MULTIPLIER)) @@ -976,7 +1003,8 @@ async def test_rpc_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Sleeping device did not update" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_sleeping_device_late_setup( @@ -1001,7 +1029,8 @@ async def test_rpc_sleeping_device_late_setup( monkeypatch.setattr(mock_rpc_device, "connected", True) mock_rpc_device.mock_initialized() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get("sensor.test_name_temperature") is not None + + assert hass.states.get("sensor.test_name_temperature") async def test_rpc_already_connected( diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index 40a364fd435..df3ab4f288d 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -47,7 +47,7 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, blocking=True, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_POSITION] == 50 await hass.services.async_call( @@ -56,7 +56,8 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.OPENING + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.OPENING await hass.services.async_call( COVER_DOMAIN, @@ -64,7 +65,8 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.CLOSING + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSING await hass.services.async_call( COVER_DOMAIN, @@ -72,10 +74,10 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.CLOSED + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSED - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-roller_0" @@ -86,11 +88,15 @@ async def test_block_device_update( monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "rollerPos", 0) await init_integration(hass, 1) - assert hass.states.get("cover.test_name").state == CoverState.CLOSED + state = hass.states.get("cover.test_name") + assert state + assert state.state == CoverState.CLOSED monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "rollerPos", 100) mock_block_device.mock_update() - assert hass.states.get("cover.test_name").state == CoverState.OPEN + state = hass.states.get("cover.test_name") + assert state + assert state.state == CoverState.OPEN async def test_block_device_no_roller_blocks( @@ -99,6 +105,7 @@ async def test_block_device_no_roller_blocks( """Test block device without roller blocks.""" monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "type", None) await init_integration(hass, 1) + assert hass.states.get("cover.test_name") is None @@ -118,7 +125,7 @@ async def test_rpc_device_services( {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, blocking=True, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_POSITION] == 50 mutate_rpc_device_status( @@ -131,7 +138,9 @@ async def test_rpc_device_services( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == CoverState.OPENING + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.OPENING mutate_rpc_device_status( monkeypatch, mock_rpc_device, "cover:0", "state", "closing" @@ -143,7 +152,9 @@ async def test_rpc_device_services( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == CoverState.CLOSING + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSING mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "closed") await hass.services.async_call( @@ -153,10 +164,10 @@ async def test_rpc_device_services( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == CoverState.CLOSED + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSED - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cover:0" @@ -166,6 +177,7 @@ async def test_rpc_device_no_cover_keys( """Test RPC device without cover keys.""" monkeypatch.delitem(mock_rpc_device.status, "cover:0") await init_integration(hass, 2) + assert hass.states.get("cover.test_cover_0") is None @@ -175,11 +187,16 @@ async def test_rpc_device_update( """Test RPC device update.""" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "closed") await init_integration(hass, 2) - assert hass.states.get("cover.test_cover_0").state == CoverState.CLOSED + + state = hass.states.get("cover.test_cover_0") + assert state + assert state.state == CoverState.CLOSED mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "open") mock_rpc_device.mock_update() - assert hass.states.get("cover.test_cover_0").state == CoverState.OPEN + state = hass.states.get("cover.test_cover_0") + assert state + assert state.state == CoverState.OPEN async def test_rpc_device_no_position_control( @@ -190,7 +207,10 @@ async def test_rpc_device_no_position_control( monkeypatch, mock_rpc_device, "cover:0", "pos_control", False ) await init_integration(hass, 2) - assert hass.states.get("cover.test_cover_0").state == CoverState.OPEN + + state = hass.states.get("cover.test_cover_0") + assert state + assert state.state == CoverState.OPEN async def test_rpc_cover_tilt( @@ -212,11 +232,10 @@ async def test_rpc_cover_tilt( await init_integration(hass, 3) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cover:0" await hass.services.async_call( @@ -228,7 +247,7 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 50) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( @@ -240,7 +259,7 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 100) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 await hass.services.async_call( @@ -258,5 +277,5 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 10) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index 89045208d20..ca9edb19fa7 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -168,7 +168,10 @@ async def test_get_triggers_for_invalid_device_id( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - with pytest.raises(InvalidDeviceAutomationConfig): + with pytest.raises( + InvalidDeviceAutomationConfig, + match="not found while configuring device automation triggers", + ): await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, invalid_device.id ) @@ -384,7 +387,10 @@ async def test_validate_trigger_invalid_triggers( }, ) - assert "Invalid (type,subtype): ('single', 'button3')" in caplog.text + assert ( + "Invalid device automation trigger (type, subtype): ('single', 'button3')" + in caplog.text + ) async def test_rpc_no_runtime_data( diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index e184c154697..a5367408955 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -33,8 +33,7 @@ async def test_rpc_button( await init_integration(hass, 2) entity_id = "event.test_name_input_0" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( ["btn_down", "btn_up", "double_push", "long_push", "single_push", "triple_push"] @@ -42,8 +41,7 @@ async def test_rpc_button( assert state.attributes.get(ATTR_EVENT_TYPE) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == EventDeviceClass.BUTTON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:0" inject_rpc_device_event( @@ -62,7 +60,7 @@ async def test_rpc_button( ) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) == "single_push" @@ -78,11 +76,9 @@ async def test_rpc_script_1_event( await init_integration(hass, 2) entity_id = "event.test_name_test_script_js" - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") inject_rpc_device_event( monkeypatch, @@ -101,7 +97,7 @@ async def test_rpc_script_1_event( ) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) == "script_start" inject_rpc_device_event( @@ -121,7 +117,7 @@ async def test_rpc_script_1_event( ) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) != "unknown_event" @@ -135,11 +131,9 @@ async def test_rpc_script_2_event( await init_integration(hass, 2) entity_id = "event.test_name_test_script_2_js" - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -152,11 +146,9 @@ async def test_rpc_script_ble_event( await init_integration(hass, 2) entity_id = f"event.test_name_{BLE_SCRIPT_NAME}" - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") async def test_rpc_event_removal( @@ -186,15 +178,13 @@ async def test_block_event( await init_integration(hass, 1) entity_id = "event.test_name_channel_1" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_EVENT_TYPES) == unordered(["single", "long"]) assert state.attributes.get(ATTR_EVENT_TYPE) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == EventDeviceClass.BUTTON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-1" monkeypatch.setattr( @@ -206,7 +196,7 @@ async def test_block_event( mock_block_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) == "long" @@ -217,8 +207,7 @@ async def test_block_event_shix3_1( await init_integration(hass, 1, model=MODEL_I3) entity_id = "event.test_name_channel_1" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( ["double", "long", "long_single", "single", "single_long", "triple"] ) diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index ef9b8f72616..129aa812580 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -10,6 +10,7 @@ from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, MacAddressMismatchError, + RpcCallError, ) from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac import pytest @@ -306,7 +307,8 @@ async def test_sleeping_rpc_device_online_during_setup( assert "will resume when device is online" in caplog.text assert "is online (source: setup)" in caplog.text - assert hass.states.get("sensor.test_name_temperature") is not None + + assert hass.states.get("sensor.test_name_temperature") async def test_sleeping_rpc_device_offline_during_setup( @@ -335,7 +337,7 @@ async def test_sleeping_rpc_device_offline_during_setup( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get("sensor.test_name_temperature") is not None + assert hass.states.get("sensor.test_name_temperature") @pytest.mark.parametrize( @@ -359,13 +361,15 @@ async def test_entry_unload( entry = await init_integration(hass, gen) assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(entity_id).state is STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert hass.states.get(entity_id).state is STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -383,9 +387,9 @@ async def test_entry_unload_device_not_ready( mock_rpc_device: Mock, ) -> None: """Test entry unload when device is not ready.""" - entry = await init_integration(hass, gen, sleep_period=1000) - + assert (entry := await init_integration(hass, gen, sleep_period=1000)) assert entry.state is ConfigEntryState.LOADED + assert hass.states.get(entity_id) is None await hass.config_entries.async_unload(entry.entry_id) @@ -404,13 +408,15 @@ async def test_entry_unload_not_connected( with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner" ) as mock_stop_scanner: - entry = await init_integration( - hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + assert ( + entry := await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) ) - entity_id = "switch.test_switch_0" - assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(entity_id).state is STATE_ON + + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON assert not mock_stop_scanner.call_count monkeypatch.setattr(mock_rpc_device, "connected", False) @@ -433,13 +439,15 @@ async def test_entry_unload_not_connected_but_we_think_we_are( "homeassistant.components.shelly.coordinator.async_stop_scanner", side_effect=DeviceConnectionError, ) as mock_stop_scanner: - entry = await init_integration( - hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + assert ( + entry := await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) ) - entity_id = "switch.test_switch_0" - assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(entity_id).state is STATE_ON + + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON assert not mock_stop_scanner.call_count monkeypatch.setattr(mock_rpc_device, "connected", False) @@ -472,7 +480,9 @@ async def test_entry_missing_gen(hass: HomeAssistant, mock_block_device: Mock) - entry = await init_integration(hass, None) assert entry.state is ConfigEntryState.LOADED - assert hass.states.get("switch.test_name_channel_1").state is STATE_ON + + assert (state := hass.states.get("switch.test_name_channel_1")) + assert state.state == STATE_ON async def test_entry_missing_port(hass: HomeAssistant) -> None: @@ -555,3 +565,17 @@ async def test_bluetooth_cleanup_on_remove_entry( remove_mock.assert_called_once_with( hass, format_mac(bluetooth_mac_from_primary_mac(entry.unique_id)).upper() ) + + +async def test_device_script_getcode_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test device script get code error.""" + monkeypatch.setattr( + mock_rpc_device, "script_getcode", AsyncMock(side_effect=RpcCallError(0)) + ) + + entry = await init_integration(hass, 2) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index 482821aa966..0dab06f53a9 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -65,18 +65,17 @@ async def test_block_device_rgbw_bulb( await init_integration(hass, 1, model=MODEL_BULB) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGBW_COLOR] == (45, 55, 65, 70) - assert attributes[ATTR_BRIGHTNESS] == 48 - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + assert state.attributes[ATTR_RGBW_COLOR] == (45, 55, 65, 70) + assert state.attributes[ATTR_BRIGHTNESS] == 48 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ ColorMode.COLOR_TEMP, ColorMode.RGBW, ] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT - assert len(attributes[ATTR_EFFECT_LIST]) == 7 - assert attributes[ATTR_EFFECT] == "Off" + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert len(state.attributes[ATTR_EFFECT_LIST]) == 7 + assert state.attributes[ATTR_EFFECT] == "Off" # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -89,7 +88,7 @@ async def test_block_device_rgbw_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, RGBW = [70, 80, 90, 20], brightness = 33, effect = Flash @@ -108,13 +107,12 @@ async def test_block_device_rgbw_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", gain=13, brightness=13, red=70, green=80, blue=90, white=30, effect=3 ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGBW - assert attributes[ATTR_RGBW_COLOR] == (70, 80, 90, 30) - assert attributes[ATTR_BRIGHTNESS] == 33 - assert attributes[ATTR_EFFECT] == "Flash" + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGBW + assert state.attributes[ATTR_RGBW_COLOR] == (70, 80, 90, 30) + assert state.attributes[ATTR_BRIGHTNESS] == 33 + assert state.attributes[ATTR_EFFECT] == "Flash" # Turn on, COLOR_TEMP_KELVIN = 3500 mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -127,14 +125,12 @@ async def test_block_device_rgbw_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", temp=3500, mode="white" ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_0" @@ -154,21 +150,20 @@ async def test_block_device_rgb_bulb( await init_integration(hass, 1, model=MODEL_BULB_RGBW) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGB_COLOR] == (45, 55, 65) - assert attributes[ATTR_BRIGHTNESS] == 48 - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + assert state.attributes[ATTR_RGB_COLOR] == (45, 55, 65) + assert state.attributes[ATTR_BRIGHTNESS] == 48 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ ColorMode.COLOR_TEMP, ColorMode.RGB, ] assert ( - attributes[ATTR_SUPPORTED_FEATURES] + state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION ) - assert len(attributes[ATTR_EFFECT_LIST]) == 4 - assert attributes[ATTR_EFFECT] == "Off" + assert len(state.attributes[ATTR_EFFECT_LIST]) == 4 + assert state.attributes[ATTR_EFFECT] == "Off" # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -181,7 +176,7 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, RGB = [70, 80, 90], brightness = 33, effect = Flash @@ -200,13 +195,12 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", gain=13, brightness=13, red=70, green=80, blue=90, effect=3 ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGB - assert attributes[ATTR_RGB_COLOR] == (70, 80, 90) - assert attributes[ATTR_BRIGHTNESS] == 33 - assert attributes[ATTR_EFFECT] == "Flash" + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGB + assert state.attributes[ATTR_RGB_COLOR] == (70, 80, 90) + assert state.attributes[ATTR_BRIGHTNESS] == 33 + assert state.attributes[ATTR_EFFECT] == "Flash" # Turn on, COLOR_TEMP_KELVIN = 3500 mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -219,11 +213,10 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", temp=3500, mode="white" ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 # Turn on with unsupported effect mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -236,14 +229,13 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", mode="color" ) - state = hass.states.get(entity_id) - attributes = state.attributes + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_EFFECT] == "Off" + assert state.attributes[ATTR_EFFECT] == "Off" assert "Effect 'Breath' not supported" in caplog.text - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_1" @@ -272,12 +264,11 @@ async def test_block_device_white_bulb( await init_integration(hass, 1, model=MODEL_VINTAGE_V2) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_BRIGHTNESS] == 128 - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION + assert state.attributes[ATTR_BRIGHTNESS] == 128 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -290,7 +281,7 @@ async def test_block_device_white_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, brightness = 33 @@ -304,13 +295,11 @@ async def test_block_device_white_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", gain=13, brightness=13 ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_BRIGHTNESS] == 33 + assert state.attributes[ATTR_BRIGHTNESS] == 33 - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_1" @@ -343,9 +332,8 @@ async def test_block_device_support_transition( await init_integration(hass, 1, model=model) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes - assert attributes[ATTR_SUPPORTED_FEATURES] & LightEntityFeature.TRANSITION + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_SUPPORTED_FEATURES] & LightEntityFeature.TRANSITION # Turn on, TRANSITION = 4 mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -358,7 +346,7 @@ async def test_block_device_support_transition( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", transition=4000 ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON # Turn off, TRANSITION = 6, limit to 5000ms @@ -372,11 +360,10 @@ async def test_block_device_support_transition( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off", transition=5000 ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_1" @@ -403,14 +390,14 @@ async def test_block_device_relay_app_type_light( mock_block_device.blocks[RELAY_BLOCK_ID], "description", "relay_1" ) await init_integration(hass, 1) + assert hass.states.get("switch.test_name_channel_1") is None # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] - assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 # Turn off mock_block_device.blocks[RELAY_BLOCK_ID].set_state.reset_mock() @@ -423,7 +410,7 @@ async def test_block_device_relay_app_type_light( mock_block_device.blocks[RELAY_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on @@ -437,11 +424,10 @@ async def test_block_device_relay_app_type_light( mock_block_device.blocks[RELAY_BLOCK_ID].set_state.assert_called_once_with( turn="on" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_1" @@ -451,6 +437,7 @@ async def test_block_device_no_light_blocks( """Test block device without light blocks.""" monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "type", "roller") await init_integration(hass, 1) + assert hass.states.get("light.test_name_channel_1") is None @@ -473,7 +460,9 @@ async def test_rpc_device_switch_type_lights_mode( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == STATE_ON + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON mutate_rpc_device_status(monkeypatch, mock_rpc_device, "switch:0", "output", False) await hass.services.async_call( @@ -483,10 +472,11 @@ async def test_rpc_device_switch_type_lights_mode( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_OFF - entry = entity_registry.async_get(entity_id) - assert entry + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-switch:0" @@ -510,7 +500,8 @@ async def test_rpc_light( ) mock_rpc_device.call_rpc.assert_called_once_with("Light.Set", {"id": 0, "on": True}) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 135 @@ -528,7 +519,8 @@ async def test_rpc_light( mock_rpc_device.call_rpc.assert_called_once_with( "Light.Set", {"id": 0, "on": False} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, brightness = 33 @@ -547,7 +539,8 @@ async def test_rpc_light( mock_rpc_device.call_rpc.assert_called_once_with( "Light.Set", {"id": 0, "on": True, "brightness": 13} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 33 @@ -565,7 +558,8 @@ async def test_rpc_light( mock_rpc_device.call_rpc.assert_called_once_with( "Light.Set", {"id": 0, "on": True, "transition_duration": 10.1} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON # Turn off, transition = 0.4, should be limited to 0.5 @@ -584,11 +578,10 @@ async def test_rpc_light( "Light.Set", {"id": 0, "on": False, "transition_duration": 0.5} ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light:0" @@ -606,12 +599,11 @@ async def test_rpc_device_rgb_profile( await init_integration(hass, 2) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGB_COLOR] == (45, 55, 65) - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGB] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION + assert state.attributes[ATTR_RGB_COLOR] == (45, 55, 65) + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGB] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn on, RGB = [70, 80, 90] await hass.services.async_call( @@ -628,14 +620,12 @@ async def test_rpc_device_rgb_profile( "RGB.Set", {"id": 0, "on": True, "rgb": [70, 80, 90]} ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGB - assert attributes[ATTR_RGB_COLOR] == (70, 80, 90) + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGB + assert state.attributes[ATTR_RGB_COLOR] == (70, 80, 90) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-rgb:0" @@ -653,12 +643,11 @@ async def test_rpc_device_rgbw_profile( await init_integration(hass, 2) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGBW_COLOR] == (21, 22, 23, 120) - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBW] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION + assert state.attributes[ATTR_RGBW_COLOR] == (21, 22, 23, 120) + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBW] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn on, RGBW = [72, 82, 92, 128] await hass.services.async_call( @@ -678,14 +667,12 @@ async def test_rpc_device_rgbw_profile( "RGBW.Set", {"id": 0, "on": True, "rgb": [72, 82, 92], "white": 128} ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGBW - assert attributes[ATTR_RGBW_COLOR] == (72, 82, 92, 128) + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGBW + assert state.attributes[ATTR_RGBW_COLOR] == (72, 82, 92, 128) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-rgbw:0" @@ -730,9 +717,11 @@ async def test_rpc_rgbw_device_light_mode_remove_others( # verify we have 4 lights for i in range(SHELLY_PLUS_RGBW_CHANNELS): entity_id = f"light.test_light_{i}" - assert hass.states.get(entity_id).state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-light:{i}" # verify RGB & RGBW entities removed @@ -793,9 +782,11 @@ async def test_rpc_rgbw_device_rgb_w_modes_remove_others( # verify we have RGB/w light entity_id = f"light.test_{active_mode}_0" - assert hass.states.get(entity_id).state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{active_mode}:0" # verify light & RGB/W entities removed @@ -823,8 +814,7 @@ async def test_rpc_cct_light( await init_integration(hass, 2) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cct:0" # Turn off @@ -836,7 +826,8 @@ async def test_rpc_cct_light( ) mock_rpc_device.call_rpc.assert_called_once_with("CCT.Set", {"id": 0, "on": False}) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on @@ -851,7 +842,8 @@ async def test_rpc_cct_light( mock_rpc_device.mock_update() mock_rpc_device.call_rpc.assert_called_once_with("CCT.Set", {"id": 0, "on": True}) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP assert state.attributes[ATTR_BRIGHTNESS] == 196 # 77% of 255 @@ -874,7 +866,8 @@ async def test_rpc_cct_light( mock_rpc_device.call_rpc.assert_called_once_with( "CCT.Set", {"id": 0, "on": True, "brightness": 88} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 224 # 88% of 255 @@ -894,7 +887,8 @@ async def test_rpc_cct_light( mock_rpc_device.call_rpc.assert_called_once_with( "CCT.Set", {"id": 0, "on": True, "ct": 4444} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4444 diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 6bddd1eeb23..41002917d86 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock -from aioshelly.const import MODEL_BLU_GATEWAY_G3 +from aioshelly.const import BLU_TRV_TIMEOUT, MODEL_BLU_GATEWAY_G3 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest from syrupy import SnapshotAssertion @@ -18,7 +18,7 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, NumberMode, ) -from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN +from homeassistant.components.shelly.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State @@ -54,15 +54,16 @@ async def test_block_number_update( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "50" + assert (state := hass.states.get(entity_id)) + assert state.state == "50" monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valvePos", 30) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == "30" + assert (state := hass.states.get(entity_id)) + assert state.state == "30" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-device_0-valvePos" @@ -103,14 +104,16 @@ async def test_block_restored_number( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "40" + assert (state := hass.states.get(entity_id)) + assert state.state == "40" # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "50" + assert (state := hass.states.get(entity_id)) + assert state.state == "50" async def test_block_restored_number_no_last_state( @@ -141,14 +144,16 @@ async def test_block_restored_number_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "50" + assert (state := hass.states.get(entity_id)) + assert state.state == "50" async def test_block_number_set_value( @@ -200,7 +205,10 @@ async def test_block_set_value_connection_error( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while calling action for number.test_name_valve_position of Test name", + ): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -302,8 +310,7 @@ async def test_rpc_device_virtual_number( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "12.3" assert state.attributes.get(ATTR_MIN) == 0 assert state.attributes.get(ATTR_MAX) == 100 @@ -311,13 +318,13 @@ async def test_rpc_device_virtual_number( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit assert state.attributes.get(ATTR_MODE) is mode - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-number:203-number" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 78.9) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "78.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "78.9" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 56.7) await hass.services.async_call( @@ -327,7 +334,8 @@ async def test_rpc_device_virtual_number( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "56.7" + assert (state := hass.states.get(entity_id)) + assert state.state == "56.7" async def test_rpc_remove_virtual_number_when_mode_label( @@ -365,8 +373,7 @@ async def test_rpc_remove_virtual_number_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_number_when_orphaned( @@ -390,8 +397,7 @@ async def test_rpc_remove_virtual_number_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_blu_trv_number_entity( @@ -427,7 +433,8 @@ async def test_blu_trv_ext_temp_set_value( # After HA start the state should be unknown because there was no previous external # temperature report - assert hass.states.get(entity_id).state is STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN await hass.services.async_call( NUMBER_DOMAIN, @@ -449,7 +456,8 @@ async def test_blu_trv_ext_temp_set_value( BLU_TRV_TIMEOUT, ) - assert hass.states.get(entity_id).state == "22.2" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.2" async def test_blu_trv_valve_pos_set_value( @@ -465,7 +473,8 @@ async def test_blu_trv_valve_pos_set_value( entity_id = f"{NUMBER_DOMAIN}.trv_name_valve_position" - assert hass.states.get(entity_id).state == "0" + assert (state := hass.states.get(entity_id)) + assert state.state == "0" monkeypatch.setitem(mock_blu_trv.status["blutrv:200"], "pos", 20) await hass.services.async_call( @@ -490,4 +499,5 @@ async def test_blu_trv_valve_pos_set_value( # device only accepts int for 'pos' value assert isinstance(mock_blu_trv.call_rpc.call_args[0][1]["params"]["pos"], int) - assert hass.states.get(entity_id).state == "20" + assert (state := hass.states.get(entity_id)) + assert state.state == "20" diff --git a/tests/components/shelly/test_select.py b/tests/components/shelly/test_select.py index 0a6eb2a5843..39e426baa58 100644 --- a/tests/components/shelly/test_select.py +++ b/tests/components/shelly/test_select.py @@ -56,8 +56,7 @@ async def test_rpc_device_virtual_enum( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == expected_state assert state.attributes.get(ATTR_OPTIONS) == [ "Title 1", @@ -65,13 +64,14 @@ async def test_rpc_device_virtual_enum( "option 3", ] - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-enum:203-enum" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "option 2") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "option 2" + + assert (state := hass.states.get(entity_id)) + assert state.state == "option 2" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "option 1") await hass.services.async_call( @@ -83,7 +83,9 @@ async def test_rpc_device_virtual_enum( # 'Title 1' corresponds to 'option 1' assert mock_rpc_device.call_rpc.call_args[0][1] == {"id": 203, "value": "option 1"} mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "Title 1" + + assert (state := hass.states.get(entity_id)) + assert state.state == "Title 1" async def test_rpc_remove_virtual_enum_when_mode_label( @@ -122,8 +124,7 @@ async def test_rpc_remove_virtual_enum_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_enum_when_orphaned( @@ -147,5 +148,4 @@ async def test_rpc_remove_virtual_enum_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 5c1f03de3e8..7edd38a4b31 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -40,7 +40,6 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from . import ( - get_entity_state, init_integration, mock_polling_rpc_update, mock_rest_update, @@ -66,15 +65,16 @@ async def test_block_sensor( entity_id = f"{SENSOR_DOMAIN}.test_name_channel_1_power" await init_integration(hass, 1) - assert hass.states.get(entity_id).state == "53.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "53.4" monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "power", 60.1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == "60.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "60.1" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-power" @@ -85,14 +85,13 @@ async def test_energy_sensor( entity_id = f"{SENSOR_DOMAIN}.test_name_channel_1_energy" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) # 1234567.89 Wmin / 60 / 1000 = 20.5761315 kWh assert state.state == "20.5761315" # suggested unit is KWh assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-energy" @@ -111,13 +110,12 @@ async def test_power_factory_unit_migration( entity_id = f"{SENSOR_DOMAIN}.test_name_power_factor" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) # Value of 0.98 is converted to 98.0% assert state.state == "98.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-emeter_0-powerFactor" @@ -128,12 +126,11 @@ async def test_power_factory_without_unit_migration( entity_id = f"{SENSOR_DOMAIN}.test_name_power_factor" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "0.98" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-emeter_0-powerFactor" @@ -147,12 +144,14 @@ async def test_block_rest_sensor( entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "rssi") await init_integration(hass, 1) - assert hass.states.get(entity_id).state == "-64" + assert (state := hass.states.get(entity_id)) + assert state.state == "-64" monkeypatch.setitem(mock_block_device.status["wifi_sta"], "rssi", -71) await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == "-71" + assert (state := hass.states.get(entity_id)) + assert state.state == "-71" async def test_block_sleeping_sensor( @@ -175,15 +174,16 @@ async def test_block_sleeping_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "temp", 23.4) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == "23.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "23.4" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-temp" @@ -211,8 +211,7 @@ async def test_block_restored_sleeping_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "20.4" assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE @@ -222,7 +221,8 @@ async def test_block_restored_sleeping_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" async def test_block_restored_sleeping_sensor_no_last_state( @@ -246,14 +246,16 @@ async def test_block_restored_sleeping_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" async def test_block_sensor_error( @@ -266,15 +268,16 @@ async def test_block_sensor_error( entity_id = f"{SENSOR_DOMAIN}.test_name_battery" await init_integration(hass, 1) - assert hass.states.get(entity_id).state == "98" + assert (state := hass.states.get(entity_id)) + assert state.state == "98" monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "battery", -1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-device_0-battery" @@ -321,7 +324,8 @@ async def test_block_not_matched_restored_sleeping_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "20.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "20.4" # Make device online monkeypatch.setattr( @@ -331,7 +335,8 @@ async def test_block_not_matched_restored_sleeping_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "20.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "20.4" async def test_block_sensor_without_value( @@ -403,7 +408,8 @@ async def test_block_sensor_values( monkeypatch.setattr(mock_block_device.blocks[block_id], attribute, value) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == final_value + assert (state := hass.states.get(entity_id)) + assert state.state == final_value @pytest.mark.parametrize( @@ -430,7 +436,8 @@ async def test_block_shelly_air_lamp_life( ) await init_integration(hass, 1) - assert hass.states.get(entity_id).state == percentage + assert (state := hass.states.get(entity_id)) + assert state.state == percentage async def test_rpc_sensor( @@ -440,17 +447,20 @@ async def test_rpc_sensor( entity_id = f"{SENSOR_DOMAIN}.test_cover_0_power" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "85.3" + assert (state := hass.states.get(entity_id)) + assert state.state == "85.3" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "apower", "88.2") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "88.2" + assert (state := hass.states.get(entity_id)) + assert state.state == "88.2" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "apower", None) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -464,7 +474,8 @@ async def test_rpc_rssi_sensor_removal( entry = await init_integration(hass, 2) # WiFi1 enabled, do not remove sensor - assert get_entity_state(hass, entity_id) == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" # WiFi1 & WiFi2 disabled - remove sensor monkeypatch.setitem(mock_rpc_device.config["wifi"]["sta"], "enable", False) @@ -476,7 +487,9 @@ async def test_rpc_rssi_sensor_removal( monkeypatch.setitem(mock_rpc_device.config["wifi"]["sta1"], "enable", True) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == "-63" + + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" async def test_rpc_illuminance_sensor( @@ -486,10 +499,10 @@ async def test_rpc_illuminance_sensor( entity_id = f"{SENSOR_DOMAIN}.test_name_illuminance" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "345" + assert (state := hass.states.get(entity_id)) + assert state.state == "345" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-illuminance:0-illuminance" @@ -503,17 +516,18 @@ async def test_rpc_sensor_error( entity_id = f"{SENSOR_DOMAIN}.test_name_voltmeter" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "4.321" + assert (state := hass.states.get(entity_id)) + assert state.state == "4.321" mutate_rpc_device_status( monkeypatch, mock_rpc_device, "voltmeter:100", "voltage", None ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-voltmeter:100-voltmeter" @@ -528,15 +542,16 @@ async def test_rpc_polling_sensor( entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "wifi-rssi") await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "wifi", "rssi", "-70") await mock_polling_rpc_update(hass, freezer) - assert hass.states.get(entity_id).state == "-70" + assert (state := hass.states.get(entity_id)) + assert state.state == "-70" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-wifi-rssi" @@ -567,12 +582,14 @@ async def test_rpc_sleeping_sensor( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "temperature:0", "tC", 23.4) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "23.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "23.4" async def test_rpc_restored_sleeping_sensor( @@ -600,7 +617,8 @@ async def test_rpc_restored_sleeping_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "21.0" + assert (state := hass.states.get(entity_id)) + assert state.state == "21.0" # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) @@ -611,7 +629,8 @@ async def test_rpc_restored_sleeping_sensor( mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" async def test_rpc_restored_sleeping_sensor_no_last_state( @@ -637,7 +656,8 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) @@ -648,7 +668,8 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -658,36 +679,32 @@ async def test_rpc_em1_sensors( """Test RPC sensors for EM1 component.""" await init_integration(hass, 2) - state = hass.states.get("sensor.test_name_em0_power") - assert state + assert (state := hass.states.get("sensor.test_name_em0_power")) assert state.state == "85.3" - entry = entity_registry.async_get("sensor.test_name_em0_power") - assert entry + assert (entry := entity_registry.async_get("sensor.test_name_em0_power")) assert entry.unique_id == "123456789ABC-em1:0-power_em1" - state = hass.states.get("sensor.test_name_em1_power") - assert state + assert (state := hass.states.get("sensor.test_name_em1_power")) assert state.state == "123.3" - entry = entity_registry.async_get("sensor.test_name_em1_power") - assert entry + assert (entry := entity_registry.async_get("sensor.test_name_em1_power")) assert entry.unique_id == "123456789ABC-em1:1-power_em1" - state = hass.states.get("sensor.test_name_em0_total_active_energy") - assert state + assert (state := hass.states.get("sensor.test_name_em0_total_active_energy")) assert state.state == "123.4564" - entry = entity_registry.async_get("sensor.test_name_em0_total_active_energy") - assert entry + assert ( + entry := entity_registry.async_get("sensor.test_name_em0_total_active_energy") + ) assert entry.unique_id == "123456789ABC-em1data:0-total_act_energy" - state = hass.states.get("sensor.test_name_em1_total_active_energy") - assert state + assert (state := hass.states.get("sensor.test_name_em1_total_active_energy")) assert state.state == "987.6543" - entry = entity_registry.async_get("sensor.test_name_em1_total_active_energy") - assert entry + assert ( + entry := entity_registry.async_get("sensor.test_name_em1_total_active_energy") + ) assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" @@ -713,7 +730,7 @@ async def test_rpc_sleeping_update_entity_service( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "22.9" await hass.services.async_call( @@ -724,11 +741,10 @@ async def test_rpc_sleeping_update_entity_service( ) # Entity should be available after update_entity service call - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "22.9" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-temperature:0-temperature_0" assert ( @@ -762,7 +778,8 @@ async def test_block_sleeping_update_entity_service( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" await hass.services.async_call( HA_DOMAIN, @@ -772,11 +789,10 @@ async def test_block_sleeping_update_entity_service( ) # Entity should be available after update_entity service call - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "22.1" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-temp" assert ( @@ -809,20 +825,18 @@ async def test_rpc_analog_input_sensors( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog" - assert hass.states.get(entity_id).state == "89" + assert (state := hass.states.get(entity_id)) + assert state.state == "89" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:1-analoginput" entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog_value" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "8.9" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:1-analoginput_xpercent" @@ -857,7 +871,8 @@ async def test_rpc_disabled_xpercent( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog" - assert hass.states.get(entity_id).state == "89" + assert (state := hass.states.get(entity_id)) + assert state.state == "89" entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog_value" assert hass.states.get(entity_id) is None @@ -887,23 +902,20 @@ async def test_rpc_pulse_counter_sensors( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter" - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "56174" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "pulse" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-pulse_counter" entity_id = f"{SENSOR_DOMAIN}.gas_counter_value" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "561.74" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-counter_value" @@ -938,7 +950,8 @@ async def test_rpc_disabled_xtotal_counter( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter" - assert hass.states.get(entity_id).state == "20635" + assert (state := hass.states.get(entity_id)) + assert state.state == "20635" entity_id = f"{SENSOR_DOMAIN}.gas_counter_value" assert hass.states.get(entity_id) is None @@ -968,23 +981,20 @@ async def test_rpc_pulse_counter_frequency_sensors( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency" - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "208.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfFrequency.HERTZ assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-counter_frequency" entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency_value" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "6.11" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-counter_frequency_value" @@ -1007,11 +1017,9 @@ async def test_rpc_disabled_xfreq( entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency_value" - state = hass.states.get(entity_id) - assert not state + assert hass.states.get(entity_id) is None - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.parametrize( @@ -1043,17 +1051,16 @@ async def test_rpc_device_virtual_text_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "lorem ipsum" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-text:203-text" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "dolor sit amet") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "dolor sit amet" + assert (state := hass.states.get(entity_id)) + assert state.state == "dolor sit amet" async def test_rpc_remove_text_virtual_sensor_when_mode_field( @@ -1086,8 +1093,7 @@ async def test_rpc_remove_text_virtual_sensor_when_mode_field( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_text_virtual_sensor_when_orphaned( @@ -1111,8 +1117,7 @@ async def test_rpc_remove_text_virtual_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.parametrize( @@ -1148,18 +1153,17 @@ async def test_rpc_device_virtual_number_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "34.5" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-number:203-number" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 56.7) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "56.7" + assert (state := hass.states.get(entity_id)) + assert state.state == "56.7" async def test_rpc_remove_number_virtual_sensor_when_mode_field( @@ -1197,8 +1201,7 @@ async def test_rpc_remove_number_virtual_sensor_when_mode_field( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_number_virtual_sensor_when_orphaned( @@ -1222,8 +1225,7 @@ async def test_rpc_remove_number_virtual_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.parametrize( @@ -1263,19 +1265,18 @@ async def test_rpc_device_virtual_enum_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == expected_state assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM assert state.attributes.get(ATTR_OPTIONS) == ["Title 1", "two", "three"] - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-enum:203-enum" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "two") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "two" + assert (state := hass.states.get(entity_id)) + assert state.state == "two" async def test_rpc_remove_enum_virtual_sensor_when_mode_dropdown( @@ -1317,8 +1318,7 @@ async def test_rpc_remove_enum_virtual_sensor_when_mode_dropdown( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_enum_virtual_sensor_when_orphaned( @@ -1342,8 +1342,7 @@ async def test_rpc_remove_enum_virtual_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -1374,61 +1373,51 @@ async def test_rpc_rgbw_sensors( entity_id = f"sensor.test_name_{light_type}_light_0_power" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "12.2" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-power_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_energy" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "0.045141" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-energy_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_current" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "0.23" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricCurrent.AMPERE ) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-current_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_voltage" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "12.4" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT ) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-voltage_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_device_temperature" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "54.3" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-temperature_{light_type}" @@ -1441,15 +1430,17 @@ async def test_rpc_device_sensor_goes_unavailable_on_disconnect( ) -> None: """Test RPC device with sensor goes unavailable on disconnect.""" await init_integration(hass, 2) - temp_sensor_state = hass.states.get("sensor.test_name_temperature") - assert temp_sensor_state is not None - assert temp_sensor_state.state != STATE_UNAVAILABLE + + assert (state := hass.states.get("sensor.test_name_temperature")) + assert state.state != STATE_UNAVAILABLE + monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setattr(mock_rpc_device, "initialized", False) mock_rpc_device.mock_disconnected() await hass.async_block_till_done() - temp_sensor_state = hass.states.get("sensor.test_name_temperature") - assert temp_sensor_state.state == STATE_UNAVAILABLE + + assert (state := hass.states.get("sensor.test_name_temperature")) + assert state.state == STATE_UNAVAILABLE freezer.tick(60) async_fire_time_changed(hass) @@ -1460,8 +1451,9 @@ async def test_rpc_device_sensor_goes_unavailable_on_disconnect( monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_initialized() await hass.async_block_till_done() - temp_sensor_state = hass.states.get("sensor.test_name_temperature") - assert temp_sensor_state.state != STATE_UNAVAILABLE + + assert (state := hass.states.get("sensor.test_name_temperature")) + assert state.state != STATE_UNAVAILABLE async def test_rpc_voltmeter_value( @@ -1474,13 +1466,11 @@ async def test_rpc_voltmeter_value( await init_integration(hass, 2) - state = hass.states.get(entity_id) - + assert (state := hass.states.get(entity_id)) assert state.state == "12.34" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "ppm" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-voltmeter:100-voltmeter_value" @@ -1525,8 +1515,7 @@ async def test_rpc_device_virtual_number_sensor_with_device_class( await init_integration(hass, 3) - state = hass.states.get("sensor.test_name_current_humidity") - assert state + assert (state := hass.states.get("sensor.test_name_current_humidity")) assert state.state == "34" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 0425f883ad6..824742d1798 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -28,7 +28,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import get_entity_state, init_integration, register_device, register_entity +from . import init_integration, register_device, register_entity from tests.common import mock_restore_cache @@ -42,22 +42,25 @@ async def test_block_device_services( ) -> None: """Test block device turn on/off services.""" await init_integration(hass, 1) + entity_id = "switch.test_name_channel_1" await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_name_channel_1"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get("switch.test_name_channel_1").state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_name_channel_1"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF @pytest.mark.parametrize("model", MOTION_MODELS) @@ -75,7 +78,8 @@ async def test_block_motion_switch( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # turn off await hass.services.async_call( @@ -88,7 +92,9 @@ async def test_block_motion_switch( mock_block_device.mock_update() mock_block_device.set_shelly_motion_detection.assert_called_once_with(False) - assert get_entity_state(hass, entity_id) == STATE_OFF + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF # turn on mock_block_device.set_shelly_motion_detection.reset_mock() @@ -102,7 +108,9 @@ async def test_block_motion_switch( mock_block_device.mock_update() mock_block_device.set_shelly_motion_detection.assert_called_once_with(True) - assert get_entity_state(hass, entity_id) == STATE_ON + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON @pytest.mark.parametrize("model", MOTION_MODELS) @@ -132,14 +140,16 @@ async def test_block_restored_motion_switch( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON @pytest.mark.parametrize("model", MOTION_MODELS) @@ -167,14 +177,16 @@ async def test_block_restored_motion_switch_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON @pytest.mark.parametrize( @@ -205,8 +217,7 @@ async def test_block_device_unique_ids( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - entry = entity_registry.async_get(entity) - assert entry + assert (entry := entity_registry.async_get(entity)) assert entry.unique_id == unique_id @@ -221,7 +232,10 @@ async def test_block_set_state_connection_error( ) await init_integration(hass, 1) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while calling action for switch.test_name_channel_1 of Test name", + ): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -270,11 +284,15 @@ async def test_block_device_update( """Test block device update.""" monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "output", False) await init_integration(hass, 1) - assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF + + entity_id = "switch.test_name_channel_1" + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "output", True) mock_block_device.mock_update() - assert hass.states.get("switch.test_name_channel_1").state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON async def test_block_device_no_relay_blocks( @@ -314,23 +332,26 @@ async def test_rpc_device_services( monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) + entity_id = "switch.test_switch_0" await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_switch_0"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get("switch.test_switch_0").state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON monkeypatch.setitem(mock_rpc_device.status["switch:0"], "output", False) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_switch_0"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get("switch.test_switch_0").state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_device_unique_ids( @@ -344,8 +365,7 @@ async def test_rpc_device_unique_ids( monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - entry = entity_registry.async_get("switch.test_switch_0") - assert entry + assert (entry := entity_registry.async_get("switch.test_switch_0")) assert entry.unique_id == "123456789ABC-switch:0" @@ -357,13 +377,27 @@ async def test_rpc_device_switch_type_lights_mode( mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) await init_integration(hass, 2) + assert hass.states.get("switch.test_switch_0") is None -@pytest.mark.parametrize("exc", [DeviceConnectionError, RpcCallError(-1, "error")]) +@pytest.mark.parametrize( + ("exc", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for switch.test_switch_0 of Test name", + ), + ( + RpcCallError(-1, "error"), + "RPC call error occurred while calling action for switch.test_switch_0 of Test name", + ), + ], +) async def test_rpc_set_state_errors( hass: HomeAssistant, exc: Exception, + error: str, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -373,7 +407,7 @@ async def test_rpc_set_state_errors( monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match=error): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -447,7 +481,7 @@ async def test_wall_display_relay_mode( config_entry = await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - assert hass.states.get(climate_entity_id) is not None + assert (state := hass.states.get(climate_entity_id)) assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 new_status = deepcopy(mock_rpc_device.status) @@ -460,17 +494,16 @@ async def test_wall_display_relay_mode( await hass.async_block_till_done() # the climate entity should be removed + assert hass.states.get(climate_entity_id) is None assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 0 # the switch entity should be created - state = hass.states.get(switch_entity_id) - assert state + assert (state := hass.states.get(switch_entity_id)) assert state.state == STATE_ON assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - entry = entity_registry.async_get(switch_entity_id) - assert entry + assert (entry := entity_registry.async_get(switch_entity_id)) assert entry.unique_id == "123456789ABC-switch:0" @@ -503,12 +536,10 @@ async def test_rpc_device_virtual_switch( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-boolean:200-boolean" monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", False) @@ -519,7 +550,8 @@ async def test_rpc_device_virtual_switch( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", True) await hass.services.async_call( @@ -529,7 +561,8 @@ async def test_rpc_device_virtual_switch( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON async def test_rpc_device_virtual_binary_sensor( @@ -550,8 +583,7 @@ async def test_rpc_device_virtual_binary_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert not state + assert hass.states.get(entity_id) is None async def test_rpc_remove_virtual_switch_when_mode_label( @@ -584,8 +616,7 @@ async def test_rpc_remove_virtual_switch_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_switch_when_orphaned( @@ -609,8 +640,7 @@ async def test_rpc_remove_virtual_switch_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -640,11 +670,10 @@ async def test_rpc_device_script_switch( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{key}-script" monkeypatch.setitem(mock_rpc_device.status[key], "running", False) @@ -655,8 +684,8 @@ async def test_rpc_device_script_switch( blocking=True, ) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) - assert state + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF monkeypatch.setitem(mock_rpc_device.status[key], "running", True) @@ -667,6 +696,6 @@ async def test_rpc_device_script_switch( blocking=True, ) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) - assert state + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON diff --git a/tests/components/shelly/test_text.py b/tests/components/shelly/test_text.py index 19acb856f35..a4812cc4160 100644 --- a/tests/components/shelly/test_text.py +++ b/tests/components/shelly/test_text.py @@ -47,17 +47,17 @@ async def test_rpc_device_virtual_text( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "lorem ipsum" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-text:203-text" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "dolor sit amet") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "dolor sit amet" + + assert (state := hass.states.get(entity_id)) + assert state.state == "dolor sit amet" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "sed do eiusmod") await hass.services.async_call( @@ -67,7 +67,9 @@ async def test_rpc_device_virtual_text( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "sed do eiusmod" + + assert (state := hass.states.get(entity_id)) + assert state.state == "sed do eiusmod" async def test_rpc_remove_virtual_text_when_mode_label( @@ -100,8 +102,7 @@ async def test_rpc_remove_virtual_text_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_text_when_orphaned( @@ -125,5 +126,4 @@ async def test_rpc_remove_virtual_text_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 9ea66c1acb7..51016f0cdaa 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -61,14 +61,16 @@ async def test_block_update( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - supported_feat = state.attributes[ATTR_SUPPORTED_FEATURES] - assert supported_feat == UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) await hass.services.async_call( UPDATE_DOMAIN, @@ -78,7 +80,7 @@ async def test_block_update( ) assert mock_block_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" @@ -89,15 +91,14 @@ async def test_block_update( monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2.0.0") await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-fwupdate" @@ -117,7 +118,7 @@ async def test_block_beta_update( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" @@ -129,7 +130,7 @@ async def test_block_beta_update( ) await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" @@ -145,7 +146,7 @@ async def test_block_beta_update( ) assert mock_block_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" @@ -155,15 +156,14 @@ async def test_block_beta_update( monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2.0.0-beta") await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2.0.0-beta" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-fwupdate_beta" @@ -184,14 +184,16 @@ async def test_block_update_connection_error( ) await init_integration(hass, 1) - with pytest.raises(HomeAssistantError) as excinfo: + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while triggering OTA update for Test name", + ): await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.test_name_firmware"}, blocking=True, ) - assert "Error starting OTA update" in str(excinfo.value) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -254,11 +256,12 @@ async def test_block_version_compare( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - state = hass.states.get(entity_id_latest) + assert (state := hass.states.get(entity_id_latest)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == STABLE assert state.attributes[ATTR_LATEST_VERSION] == STABLE - state = hass.states.get(entity_id_beta) + + assert (state := hass.states.get(entity_id_beta)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == STABLE assert state.attributes[ATTR_LATEST_VERSION] == BETA @@ -268,11 +271,12 @@ async def test_block_version_compare( monkeypatch.setitem(mock_block_device.status["update"], "beta_version", BETA) await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id_latest) + assert (state := hass.states.get(entity_id_latest)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == BETA assert state.attributes[ATTR_LATEST_VERSION] == STABLE - state = hass.states.get(entity_id_beta) + + assert (state := hass.states.get(entity_id_beta)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == BETA assert state.attributes[ATTR_LATEST_VERSION] == BETA @@ -296,7 +300,7 @@ async def test_rpc_update( ) await init_integration(hass, 2) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -314,7 +318,7 @@ async def test_rpc_update( assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -337,7 +341,7 @@ async def test_rpc_update( }, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 0 @@ -357,7 +361,7 @@ async def test_rpc_update( }, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 50 @@ -378,15 +382,14 @@ async def test_rpc_update( monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sys-fwupdate" @@ -417,7 +420,7 @@ async def test_rpc_sleeping_update( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -429,7 +432,7 @@ async def test_rpc_sleeping_update( monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -437,8 +440,7 @@ async def test_rpc_sleeping_update( assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None assert state.attributes[ATTR_SUPPORTED_FEATURES] == UpdateEntityFeature(0) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sys-fwupdate" @@ -469,7 +471,7 @@ async def test_rpc_restored_sleeping_update( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -486,7 +488,7 @@ async def test_rpc_restored_sleeping_update( mock_rpc_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -525,7 +527,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN # Make device online @@ -537,7 +539,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( mock_rpc_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -567,7 +569,7 @@ async def test_rpc_beta_update( ) await init_integration(hass, 2) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "1" @@ -584,7 +586,7 @@ async def test_rpc_beta_update( ) await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" @@ -614,7 +616,7 @@ async def test_rpc_beta_update( assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" @@ -637,7 +639,7 @@ async def test_rpc_beta_update( }, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 40 @@ -658,23 +660,28 @@ async def test_rpc_beta_update( monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2b") await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2b" assert state.attributes[ATTR_LATEST_VERSION] == "2b" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sys-fwupdate_beta" @pytest.mark.parametrize( ("exc", "error"), [ - (DeviceConnectionError, "OTA update connection error: DeviceConnectionError()"), - (RpcCallError(-1, "error"), "OTA update request error"), + ( + DeviceConnectionError, + "Device communication error occurred while triggering OTA update for Test name", + ), + ( + RpcCallError(-1, "error"), + "RPC call error occurred while triggering OTA update for Test name", + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -701,14 +708,13 @@ async def test_rpc_update_errors( ) await init_integration(hass, 2) - with pytest.raises(HomeAssistantError) as excinfo: + with pytest.raises(HomeAssistantError, match=error): await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.test_name_firmware"}, blocking=True, ) - assert error in str(excinfo.value) @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py index 9dc8597120a..7bf9e3b5f1a 100644 --- a/tests/components/shelly/test_valve.py +++ b/tests/components/shelly/test_valve.py @@ -25,11 +25,11 @@ async def test_block_device_gas_valve( await init_integration(hass, 1, MODEL_GAS) entity_id = "valve.test_name_valve" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-valve_0-valve" - assert hass.states.get(entity_id).state == ValveState.CLOSED + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.CLOSED await hass.services.async_call( VALVE_DOMAIN, @@ -38,16 +38,14 @@ async def test_block_device_gas_valve( blocking=True, ) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.OPENING monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "opened") mock_block_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.OPEN await hass.services.async_call( @@ -57,14 +55,12 @@ async def test_block_device_gas_valve( blocking=True, ) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.CLOSING monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "closed") mock_block_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.CLOSED diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index e87d1a8bcdf..fce344b57a7 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import AsyncMock -from pysmartthings.models import Attribute, Capability, DeviceEvent +from pysmartthings import Attribute, Capability, DeviceEvent from syrupy import SnapshotAssertion from homeassistant.components.smartthings.const import MAIN @@ -55,6 +55,7 @@ async def trigger_update( attribute: Attribute, value: str | float | dict[str, Any] | list[Any] | None, data: dict[str, Any] | None = None, + component: str = MAIN, ) -> None: """Trigger an update.""" event = DeviceEvent( @@ -62,7 +63,7 @@ async def trigger_update( "abc", "abc", device_id, - MAIN, + component, capability, attribute, value, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index d26805eb04b..277c327744f 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator import time from unittest.mock import AsyncMock, patch -from pysmartthings.models import ( +from pysmartthings import ( DeviceResponse, DeviceStatus, LocationResponse, @@ -91,7 +91,9 @@ def mock_smartthings() -> Generator[AsyncMock]: @pytest.fixture( params=[ + "da_ac_airsensor_01001", "da_ac_rac_000001", + "da_ac_rac_000003", "da_ac_rac_100001", "da_ac_rac_01001", "multipurpose_sensor", @@ -104,7 +106,9 @@ def mock_smartthings() -> Generator[AsyncMock]: "ge_in_wall_smart_dimmer", "centralite", "da_ref_normal_000001", + "da_ref_normal_01011", "vd_network_audio_002s", + "vd_sensor_light_2023", "iphone", "da_sac_ehs_000001_sub", "da_wm_dw_000001", @@ -112,8 +116,10 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_wd_000001_1", "da_wm_wm_000001", "da_wm_wm_000001_1", + "da_wm_sc_000001", "da_rvc_normal_000001", "da_ks_microwave_0101x", + "da_ks_cooktop_31001", "da_ks_range_0101x", "da_ks_oven_01061", "hue_color_temperature_bulb", @@ -131,6 +137,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", + "heatit_zpushwall", "generic_ef00_v1", "bosch_radiator_thermostat_ii", "im_speaker_ai_0001", @@ -138,6 +145,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "tplink_p110", "ikea_kadrilj", "aux_ac", + "hw_q80r_soundbar", ] ) def device_fixture( @@ -180,6 +188,7 @@ def mock_config_entry(expires_at: int) -> MockConfigEntry: CONF_INSTALLED_APP_ID: "123", }, version=3, + minor_version=2, ) diff --git a/tests/components/smartthings/fixtures/device_status/contact_sensor.json b/tests/components/smartthings/fixtures/device_status/contact_sensor.json index fa158d41b39..ca8c2628c99 100644 --- a/tests/components/smartthings/fixtures/device_status/contact_sensor.json +++ b/tests/components/smartthings/fixtures/device_status/contact_sensor.json @@ -36,7 +36,7 @@ "value": null }, "availableVersion": { - "value": "00000103", + "value": "00000104", "timestamp": "2025-02-09T13:59:19.101Z" }, "lastUpdateStatus": { diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json new file mode 100644 index 00000000000..903b5163335 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json @@ -0,0 +1,362 @@ +{ + "components": { + "main": { + "samsungce.rechargeableBattery": { + "chargingStatus": { + "value": "charging", + "timestamp": "2025-02-18T05:20:27.966Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-22T04:50:19.633Z" + }, + "resolution": { + "value": 1, + "timestamp": "2024-12-20T14:38:31.662Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 54, + "unit": "%", + "timestamp": "2025-03-21T07:26:16.872Z" + } + }, + "refresh": {}, + "carbonDioxideHealthConcern": { + "carbonDioxideHealthConcern": { + "value": "moderate", + "timestamp": "2025-03-21T13:40:56.560Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.sensors"], + "if": ["oic.if.baseline", "oic.if.s"], + "x.com.samsung.da.cleanLevel": "2", + "x.com.samsung.da.refresh": "Off", + "x.com.samsung.da.lastSensingTime": "1740829045", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": ["2"] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": ["2"] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": ["29", "1"] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": ["7", "1"] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": ["6", "1"] + }, + { + "x.com.samsung.da.id": "5", + "x.com.samsung.da.description": "Sensor for CO2", + "x.com.samsung.da.type": "CO2", + "x.com.samsung.da.value": ["2527", "3"] + } + ] + } + }, + "data": { + "href": "/sensors/vs/0" + }, + "timestamp": "2025-03-01T11:37:26.334Z" + } + }, + "carbonDioxideMeasurement": { + "carbonDioxide": { + "value": 1045, + "unit": "ppm", + "timestamp": "2025-03-21T15:05:44.312Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "ASM-KR-TP1-22-ACMB1M", + "timestamp": "2025-03-20T23:08:07.388Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": 2, + "unit": "CAQI", + "timestamp": "2025-03-21T15:06:39.609Z" + } + }, + "fineDustHealthConcern": { + "fineDustHealthConcern": { + "value": "good", + "timestamp": "2025-03-21T10:25:04.548Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ASM-KR-TP1-22-ACMB1M_16240426", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "di": { + "value": "a3a970ea-e09c-9c04-161b-94c934e21666", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "n": { + "value": "Samsung AirMonitor", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnmo": { + "value": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "vid": { + "value": "DA-AC-AIRSENSOR-01001", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnos": { + "value": "TizenRT 4.0", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "pi": { + "value": "a3a970ea-e09c-9c04-161b-94c934e21666", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-08-19T07:28:01.277Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": 1, + "timestamp": "2025-03-21T13:29:15.650Z" + } + }, + "veryFineDustHealthConcern": { + "veryFineDustHealthConcern": { + "value": "good", + "timestamp": "2025-03-21T02:56:21.007Z" + } + }, + "samsungce.doNotDisturb": { + "settable": { + "value": true, + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "dayOfWeek": { + "value": null + }, + "repeatMode": { + "value": null + }, + "startTime": { + "value": "14:00:00Z", + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "endTime": { + "value": "22:00:00Z", + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "activated": { + "value": false, + "timestamp": "2024-12-20T14:38:31.895Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2025-03-01T11:37:26.334Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2023-12-09T04:05:59.505Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "EXCHUODPSCTZY", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "AM0", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2024-12-20T14:38:31.716Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 23.0, + "unit": "C", + "timestamp": "2025-03-21T04:40:33.951Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": 31, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:39.609Z" + }, + "fineDustLevel": { + "value": 7, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:28.515Z" + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": 6, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:28.515Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2024-12-20T14:38:31.769Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-03-20T22:02:48.215Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2024-12-20T14:38:31.769Z" + } + }, + "dustHealthConcern": { + "dustHealthConcern": { + "value": "moderate", + "timestamp": "2025-03-21T15:06:39.609Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json index c80fcf9c298..f6cdd661a99 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json @@ -473,7 +473,7 @@ "timestamp": "2024-09-10T10:26:28.781Z" }, "acOptionalMode": { - "value": "off", + "value": "windFree", "timestamp": "2025-02-09T09:14:39.642Z" } }, diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json new file mode 100644 index 00000000000..98434aa2c5a --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json @@ -0,0 +1,585 @@ +{ + "components": { + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 48, + "unit": "%", + "timestamp": "2025-03-27T05:12:16.158Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null + }, + "airConditionerOdorControllerState": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-03-13T09:29:37.008Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2024-06-21T13:45:16.785Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto"], + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-03-13T09:29:36.789Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": "off", + "timestamp": "2025-02-08T08:54:15.661Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "ARTIK051_PRAC_20K", + "timestamp": "2025-03-27T05:12:15.284Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": [ + "off", + "sleep", + "quiet", + "smart", + "speed", + "windFree", + "windFreeSleep" + ], + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-03-26T12:20:41.095Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-27T05:41:42.291Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 0, + "timestamp": "2025-02-08T08:54:15.789Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ARTIK051_PRAC_20K_11230313", + "timestamp": "2024-06-21T13:58:04.085Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2024-06-21T13:51:35.294Z" + }, + "di": { + "value": "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-06-21T13:51:35.980Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-06-21T13:58:04.698Z" + }, + "n": { + "value": "Samsung Room A/C", + "timestamp": "2024-06-21T13:58:04.085Z" + }, + "mnmo": { + "value": "ARTIK051_PRAC_20K|10256941|60010534001411014600003200800000", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "vid": { + "value": "DA-AC-RAC-000003", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-06-21T13:51:35.294Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-06-21T13:51:35.294Z" + }, + "pi": { + "value": "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-06-21T13:45:16.329Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "low", + "timestamp": "2025-03-26T12:20:41.393Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "samsungce.dustFilterAlarm": { + "alarmThreshold": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "supportedAlarmThresholds": { + "value": [180, 300, 500, 700], + "unit": "Hour", + "timestamp": "2025-02-08T08:54:15.473Z" + } + }, + "custom.electricHepaFilter": { + "electricHepaFilterCapacity": { + "value": null + }, + "electricHepaFilterUsageStep": { + "value": null + }, + "electricHepaFilterLastResetDate": { + "value": null + }, + "electricHepaFilterStatus": { + "value": null + }, + "electricHepaFilterUsage": { + "value": null + }, + "electricHepaFilterResetType": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.spiMode", + "custom.deodorFilter", + "custom.electricHepaFilter", + "custom.periodicSensing", + "custom.doNotDisturbMode", + "custom.airConditionerOdorController", + "samsungce.individualControlLock" + ], + "timestamp": "2025-02-08T08:54:15.355Z" + } + }, + "custom.ocfResourceVersion": { + "ocfResourceUpdatedTime": { + "value": null + }, + "ocfResourceVersion": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24040101, + "timestamp": "2024-06-21T13:45:16.348Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": ["fixed", "all", "vertical", "horizontal"], + "timestamp": "2025-02-08T08:54:15.797Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-25T15:40:11.773Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 26, + "unit": "C", + "timestamp": "2025-03-26T14:19:08.047Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null + }, + "fineDustLevel": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "disabled", + "timestamp": "2025-02-08T08:54:15.726Z" + }, + "reportStateRealtime": { + "value": { + "state": "enabled", + "duration": 10, + "unit": "minute" + }, + "timestamp": "2025-03-24T08:28:07.030Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-02-08T08:54:15.726Z" + } + }, + "custom.periodicSensing": { + "automaticExecutionSetting": { + "value": null + }, + "automaticExecutionMode": { + "value": null + }, + "supportedAutomaticExecutionSetting": { + "value": null + }, + "supportedAutomaticExecutionMode": { + "value": null + }, + "periodicSensing": { + "value": null + }, + "periodicSensingInterval": { + "value": null + }, + "lastSensingTime": { + "value": null + }, + "lastSensingLevel": { + "value": null + }, + "periodicSensingStatus": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 24, + "unit": "C", + "timestamp": "2025-03-26T12:20:41.346Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "duration": 0, + "override": false + }, + "timestamp": "2025-03-24T04:56:36.855Z" + } + }, + "audioVolume": { + "volume": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-08T08:54:15.789Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 602171, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 602171, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-03-27T05:29:22Z", + "end": "2025-03-27T05:40:02Z" + }, + "timestamp": "2025-03-27T05:40:02.686Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "off", + "timestamp": "2025-03-15T05:30:11.075Z" + } + }, + "samsungce.individualControlLock": { + "lockState": { + "value": null + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2024-06-21T13:45:16.348Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2025-02-08T08:54:15.048Z" + }, + "status": { + "value": null + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": 1, + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "dustFilterUsage": { + "value": 69, + "timestamp": "2025-03-26T10:57:41.097Z" + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": "normal", + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "dustFilterCapacity": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "dustFilterResetType": { + "value": ["replaceable", "washable"], + "timestamp": "2025-02-08T08:54:15.473Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2024-06-21T13:58:08.419Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2024-06-21T13:51:39.304Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-02-08T08:54:16.767Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2025-03-24T04:56:36.855Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-08T08:54:16.685Z" + }, + "otnDUID": { + "value": "MTCPH4AI4MTYO", + "timestamp": "2025-02-08T08:54:15.626Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T08:54:15.626Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T08:54:15.626Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null + } + }, + "custom.veryFineDustFilter": { + "veryFineDustFilterStatus": { + "value": null + }, + "veryFineDustFilterResetType": { + "value": null + }, + "veryFineDustFilterUsage": { + "value": null + }, + "veryFineDustFilterLastResetDate": { + "value": null + }, + "veryFineDustFilterUsageStep": { + "value": null + }, + "veryFineDustFilterCapacity": { + "value": null + } + }, + "custom.doNotDisturbMode": { + "doNotDisturb": { + "value": null + }, + "startTime": { + "value": null + }, + "endTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json b/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json new file mode 100644 index 00000000000..5ca8f56fbbf --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json @@ -0,0 +1,508 @@ +{ + "components": { + "burner-02": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-26T05:57:23.203Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.550Z" + } + } + }, + "burner-01": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.518Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-26T05:57:23.203Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.518Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.518Z" + } + } + }, + "main": { + "custom.disabledComponents": { + "disabledComponents": { + "value": ["burner-6"], + "timestamp": "2025-03-25T18:18:28.464Z" + } + }, + "custom.userNotification": { + "message": { + "value": null + } + }, + "samsungce.remoteManagementData": { + "reportRawData": { + "value": "AgUBASCgAwAACaEDAAAM4AQAAAAA4QHwAw==", + "timestamp": "2025-03-26T07:27:58.282Z" + }, + "version": { + "value": "CT-31.0001", + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "5828", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "modelName": { + "value": "NZ64B5046GK", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "serialNumber": { + "value": "B8C878DX900290H", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "serialNumberExtra": { + "value": "N/A", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "modelClassificationCode": { + "value": "50000204001611000E00000000000000", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "description": { + "value": "N/A", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP2X_DA-KS-COOKTOP-31001", + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-26T07:27:58.478Z" + } + }, + "samsungce.errorAndAlarmState": { + "events": { + "value": [], + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "samsungce.cooktopFlexZone": { + "flexZones": { + "value": [], + "timestamp": "2025-03-26T05:57:23.671Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "Wifi", + "swType": "Wifi-Application", + "versionNumber": "80001A220811", + "description": "Aug 11 2022 08:38:36, Wifi:ws029_030, STDK : 1.7.4)" + }, + { + "id": "Micom", + "swType": "Micom Software", + "versionNumber": "240617", + "description": "Description for this micom version" + } + ], + "timestamp": "2025-03-25T18:18:28.482Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": null + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": null + } + }, + "custom.cooktopOperatingState": { + "supportedCooktopOperatingState": { + "value": ["ready", "run", "paused"], + "timestamp": "2025-03-26T07:26:39.690Z" + }, + "cooktopOperatingState": { + "value": "ready", + "timestamp": "2025-03-26T07:27:58.652Z" + } + }, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "EU", + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "modelCode": { + "value": "OZ8500B/EU2", + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "cooktop", + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "JHCB2ZD4E2KRY", + "timestamp": "2025-03-25T18:18:28.482Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.kidsLockControl": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-03-25T18:18:28.464Z" + } + } + }, + "burner-06": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.591Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.591Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "status": { + "value": null + } + } + }, + "hood": { + "samsungce.connectionState": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-03-25T18:18:28.650Z" + } + }, + "samsungce.hoodFanSpeed": { + "settableMaxFanSpeed": { + "value": 5, + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "hoodFanSpeed": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "supportedHoodFanSpeed": { + "value": [1, 2, 3, 4, 5], + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "settableMinFanSpeed": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.650Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.646Z" + }, + "status": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": null + }, + "supportedBrightnessLevel": { + "value": ["off", "mid"], + "timestamp": "2025-03-25T18:18:28.650Z" + } + } + }, + "burner-05": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.586Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.586Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "status": { + "value": null + } + } + }, + "burner-04": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.578Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-25T18:49:25.153Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.578Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.578Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.578Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.578Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.578Z" + } + } + }, + "burner-03": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-26T07:27:58.652Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.550Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json new file mode 100644 index 00000000000..350a0ee14bb --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json @@ -0,0 +1,933 @@ +{ + "components": { + "pantry-01": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "pantry-02": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "onedoor": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], + "timestamp": "2024-12-01T18:22:20.155Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "scale-10": { + "samsungce.connectionState": { + "connectionState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + }, + "samsungce.weightMeasurementCalibration": {} + }, + "scale-11": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + } + }, + "cooler": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-03-30T18:36:45.151Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode", "samsungce.temperatureSetting"], + "timestamp": "2024-12-01T18:22:22.081Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 6, + "unit": "C", + "timestamp": "2025-03-30T17:41:42.863Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 1, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "maximumSetpoint": { + "value": 7, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 1, + "maximum": 7, + "step": 1 + }, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "coolingSetpoint": { + "value": 6, + "unit": "C", + "timestamp": "2025-03-30T17:33:48.530Z" + } + } + }, + "freezer": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2024-12-01T18:22:19.331Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "custom.fridgeMode", + "samsungce.temperatureSetting", + "samsungce.freezerConvertMode" + ], + "timestamp": "2024-12-01T18:22:22.081Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": -17, + "unit": "C", + "timestamp": "2025-03-30T17:35:48.599Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -23, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "maximumSetpoint": { + "value": -15, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": -23, + "maximum": -15, + "step": 1 + }, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "coolingSetpoint": { + "value": -17, + "unit": "C", + "timestamp": "2025-03-30T17:32:34.710Z" + } + } + }, + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-03-30T18:36:45.151Z" + } + }, + "samsungce.fridgeWelcomeLighting": { + "detectionProximity": { + "value": null + }, + "supportedDetectionProximities": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.viewInside": { + "supportedFocusAreas": { + "value": null + }, + "contents": { + "value": null + }, + "lastUpdatedTime": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_REF_21K", + "timestamp": "2025-03-23T21:53:15.900Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-02-12T21:52:01.494Z" + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "A-RFWW-TP1-22-REV1_20241030", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "di": { + "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "n": { + "value": "Samsung-Refrigerator", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnmo": { + "value": "TP1X_REF_21K|00156941|00050126001611304100000030010000", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "vid": { + "value": "DA-REF-NORMAL-01011", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "pi": { + "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-12T21:51:58.927Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": "off", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "temperatureMeasurement", + "thermostatCoolingSetpoint", + "custom.fridgeMode", + "custom.deodorFilter", + "custom.waterFilter", + "custom.dustFilter", + "samsungce.viewInside", + "samsungce.fridgeWelcomeLighting", + "samsungce.sabbathMode" + ], + "timestamp": "2025-02-12T21:52:01.494Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24090102, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-02-12T21:52:00.460Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "RB0", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-02-12T21:52:00.460Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-02-12T21:52:00.460Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker", + "icemaker-02", + "icemaker-03", + "pantry-01", + "pantry-02", + "scale-10", + "scale-11", + "cvroom", + "onedoor" + ], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "duration": 0, + "override": false + }, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": null + }, + "status": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 66571, + "deltaEnergy": 19, + "power": 61, + "powerEnergy": 18.91178222020467, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-03-30T18:21:37Z", + "end": "2025-03-30T18:38:18Z" + }, + "timestamp": "2025-03-30T18:38:18.219Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2024-12-01T18:22:19.331Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2024-12-01T18:22:19.331Z" + }, + "protocolType": { + "value": ["helper_hotspot"], + "timestamp": "2024-12-01T18:22:19.331Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": "passed", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "status": { + "value": "ready", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null + }, + "dustFilterUsage": { + "value": null + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": null + }, + "dustFilterCapacity": { + "value": null + }, + "dustFilterResetType": { + "value": null + } + }, + "refrigeration": { + "defrost": { + "value": null + }, + "rapidCooling": { + "value": "off", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "rapidFreezing": { + "value": "off", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-03-06T23:10:37.429Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2024-12-01T18:22:20.756Z" + }, + "energySavingLevel": { + "value": 1, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": [1, 2], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "energySavingOperation": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2024-12-01T18:55:10.062Z" + }, + "otnDUID": { + "value": "MTCB2ZD4B6BT4", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2024-12-01T18:28:40.492Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2024-12-01T18:43:42.645Z" + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": null + }, + "waterFilterResetType": { + "value": null + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": null + }, + "waterFilterStatus": { + "value": null + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "icemaker-02": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "icemaker-03": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_sc_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_sc_000001.json new file mode 100644 index 00000000000..d52b5186db3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_sc_000001.json @@ -0,0 +1,929 @@ +{ + "components": { + "main": { + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20299141", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "3801010200151107020100FF00000000", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "description": { + "value": "DA_DF_TP2_20_COMMON_DF8500A/DC92-02995A_0010", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_DF_TP2_20_COMMON", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.steamClosetCycle": { + "supportedCycles": { + "value": [ + { + "cycle": "22", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6106", + "default": "off", + "options": ["off", "on"] + } + } + }, + { + "cycle": "23", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "32", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "09", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "12", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "0C", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "31", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "0B", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "10", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "0A", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6106", + "default": "off", + "options": ["off", "on"] + } + } + }, + { + "cycle": "14", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "13", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6106", + "default": "off", + "options": ["off", "on"] + } + } + }, + { + "cycle": "16", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "24", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6206", + "default": "on", + "options": ["off", "on"] + } + } + }, + { + "cycle": "25", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6206", + "default": "on", + "options": ["off", "on"] + } + } + }, + { + "cycle": "2F", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6206", + "default": "on", + "options": ["off", "on"] + } + } + }, + { + "cycle": "20", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6204", + "default": "on", + "options": ["on"] + } + } + }, + { + "cycle": "0F", + "supportedOptions": { + "keepFresh": { + "raw": "66F0", + "default": "off", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6204", + "default": "on", + "options": ["on"] + } + } + }, + { + "cycle": "27", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "30", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "15", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "1A", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "1B", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "1C", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "2D", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "07", + "supportedOptions": { + "keepFresh": { + "raw": "66F0", + "default": "off", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "08", + "supportedOptions": { + "keepFresh": { + "raw": "66F0", + "default": "off", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + } + ], + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "steamClosetCycle": { + "value": "Table_00_Course_22", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_DF_TP2_20_COMMON_30230807", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "di": { + "value": "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "n": { + "value": "[airdresser] Samsung", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnmo": { + "value": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "vid": { + "value": "DA-WM-SC-000001", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnos": { + "value": "TizenRT 2.0 + IPv6", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "pi": { + "value": "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-14T01:42:53.834Z" + } + }, + "samsungce.steamClosetCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "presets": { + "value": { + "F1": {}, + "F2": {}, + "F3": {}, + "F4": {}, + "F5": {}, + "F6": {}, + "F7": {}, + "F8": {}, + "F9": {}, + "FA": {} + }, + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "custom.steamClosetWrinklePrevent", + "custom.veryFineDustFilter", + "demandResponseLoadControl", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate" + ], + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-02T07:55:47.237Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "A00", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.steamClosetKeepFreshMode": { + "operatingState": { + "value": "ready", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "status": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 207500, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-10T22:51:59Z", + "end": "2025-02-11T08:21:17Z" + }, + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "dryerOperatingState": { + "completionTime": { + "value": "2025-02-11T09:00:17Z", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "refresh": {}, + "samsungce.steamClosetSanitizeMode": { + "status": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON_DF8500A/DC92-02995A_0010", + "x.com.samsung.da.serialNum": "1EG158TW400002M", + "x.com.samsung.da.otnDUID": "MTCHUODP5V4FA", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "A00", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02673A230807(F821)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_20299141210618090FFFFF202995412203111604FFFF(015E2029914120299541_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "21061809,22031116", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_2023564319111852041FFFFFFFFFFFFFFFFFFFFFFFFE(015E20235643FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "19111852,FFFFFFFF" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2024-03-06T11:24:05.312Z" + } + }, + "samsungce.steamClosetDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.steamClosetAutoCycleLink": { + "steamClosetAutoCycleLink": { + "value": "on", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "custom.steamClosetWrinklePrevent": { + "steamClosetWrinklePrevent": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "supportedCourses": { + "value": [ + "22", + "23", + "32", + "09", + "12", + "0C", + "31", + "0B", + "10", + "0A", + "14", + "13", + "16", + "24", + "25", + "2F", + "20", + "0F", + "27", + "30", + "15", + "1A", + "1B", + "1C", + "2D", + "07", + "08" + ], + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.steamClosetOperatingState": { + "supportedSteamClosetJobState": { + "value": ["none", "steaming", "airwashing", "drying", "finish"], + "timestamp": "2025-02-09T22:16:19.221Z" + }, + "completionTime": { + "value": "2025-02-11T09:00:17Z", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "steamClosetMachineState": { + "value": "stop", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "supportedSteamClosetMachineState": { + "value": ["stop", "run", "pause"], + "timestamp": "2023-06-23T16:00:41.238Z" + }, + "steamClosetJobState": { + "value": "none", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-10T22:53:25.928Z" + }, + "remainingTimeStr": { + "value": "00:39", + "timestamp": "2025-02-10T22:53:25.928Z" + }, + "steamClosetDelayEndTime": { + "value": null + }, + "remainingTime": { + "value": 39, + "unit": "min", + "timestamp": "2025-02-10T22:53:25.928Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2024-03-06T11:24:06.106Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2024-03-06T11:24:06.106Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-09T17:33:28.019Z" + }, + "otnDUID": { + "value": "MTCHUODP5V4FA", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2023-06-23T16:00:41.636Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-09T17:33:28.019Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.veryFineDustFilter": { + "veryFineDustFilterStatus": { + "value": null + }, + "veryFineDustFilterResetType": { + "value": null + }, + "veryFineDustFilterUsage": { + "value": null + }, + "veryFineDustFilterLastResetDate": { + "value": null + }, + "veryFineDustFilterUsageStep": { + "value": null + }, + "veryFineDustFilterCapacity": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json b/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json new file mode 100644 index 00000000000..591d1128ea0 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json @@ -0,0 +1,116 @@ +{ + "components": { + "button4": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-02-10T08:01:11.326Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.695Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.717Z" + } + } + }, + "button5": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-03-09T16:37:40.792Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.762Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.813Z" + } + } + }, + "button2": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-02-10T08:00:57.171Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.861Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.906Z" + } + } + }, + "button3": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-01-30T05:53:00.663Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.852Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.848Z" + } + } + }, + "button6": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2024-10-02T13:11:07.346Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.816Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.848Z" + } + } + }, + "main": { + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-03-10T10:32:19.528Z" + }, + "type": { + "value": null + } + } + }, + "button1": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-01-30T05:52:46.718Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.717Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.767Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/hw_q80r_soundbar.json b/tests/components/smartthings/fixtures/device_status/hw_q80r_soundbar.json new file mode 100644 index 00000000000..8cd0d3e35a9 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hw_q80r_soundbar.json @@ -0,0 +1,173 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-03-23T01:10:02.207Z" + }, + "playbackStatus": { + "value": "playing", + "timestamp": "2025-03-23T01:19:44.622Z" + } + }, + "samsungvd.groupInfo": { + "role": { + "value": "none", + "timestamp": "2025-03-23T01:17:10.965Z" + }, + "channel": { + "value": "all", + "timestamp": "2025-03-23T01:17:10.965Z" + }, + "masterName": { + "value": "", + "timestamp": "2025-03-23T01:17:10.965Z" + }, + "status": { + "value": "single", + "timestamp": "2025-03-23T01:17:10.965Z" + } + }, + "audioVolume": { + "volume": { + "value": 1, + "unit": "%", + "timestamp": "2025-03-23T01:17:13.754Z" + } + }, + "ocf": { + "st": { + "value": "NONE", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mndt": { + "value": "2018-01-01", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnfv": { + "value": "HW-Q80RWWB-1012.6", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnhw": { + "value": "0-0", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "di": { + "value": "afcf3b91-48fe-4c3b-ab44-ddff2a0a6577", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnsl": { + "value": "http://www.samsung.com/sec/audio-video/", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "n": { + "value": "[AV] Samsung Soundbar Q80R", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnmo": { + "value": "Q80R", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "vid": { + "value": "VD-NetworkAudio-001S", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnpv": { + "value": "Tizen 4.0", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnos": { + "value": "4.1.10", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "pi": { + "value": "afcf3b91-48fe-4c3b-ab44-ddff2a0a6577", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-18T21:07:25.406Z" + } + }, + "mediaInputSource": { + "supportedInputSources": { + "value": ["wifi", "bluetooth", "HDMI1", "HDMI2", "digital"], + "timestamp": "2025-03-23T01:18:01.663Z" + }, + "inputSource": { + "value": "wifi", + "timestamp": "2025-03-23T01:18:01.663Z" + } + }, + "refresh": {}, + "audioNotification": {}, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-03-23T01:17:11.024Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.networkaudio.soundmode"], + "if": ["oic.if.a", "oic.if.baseline"], + "x.com.samsung.networkaudio.soundmode": "standard" + } + }, + "data": { + "href": "/sec/networkaudio/soundmode" + }, + "timestamp": "2023-07-16T23:16:55.582Z" + } + }, + "samsungvd.audioInputSource": { + "supportedInputSources": { + "value": ["wifi", "bluetooth", "HDMI1", "HDMI2", "digital"], + "timestamp": "2025-03-23T01:18:01.663Z" + }, + "inputSource": { + "value": "wificp", + "timestamp": "2025-03-23T01:18:01.663Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-23T01:19:44.837Z" + } + }, + "audioTrackData": { + "totalTime": { + "value": null, + "timestamp": "2020-07-30T16:09:09.109Z" + }, + "audioTrackData": { + "value": { + "title": "Never Gonna Give You Up", + "artist": "Rick Astley" + }, + "timestamp": "2025-03-23T01:19:15.067Z" + }, + "elapsedTime": { + "value": null, + "timestamp": "2020-07-30T16:09:09.109Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_sensor_light_2023.json b/tests/components/smartthings/fixtures/device_status/vd_sensor_light_2023.json new file mode 100644 index 00000000000..cffefa20c4a --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_sensor_light_2023.json @@ -0,0 +1,95 @@ +{ + "components": { + "main": { + "ocf": { + "st": { + "value": "2025-01-14T08:07:36Z", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mndt": { + "value": "2023-01-01", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnfv": { + "value": "latest", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "di": { + "value": "5cc1c096-98b9-460c-8f1c-1045509ec605", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "n": { + "value": "Light Sensor - 55 The Frame", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnmo": { + "value": "QE55LS03DAUXXN", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "vid": { + "value": "VD-Sensor.Light-2023", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnpv": { + "value": "8.0", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "pi": { + "value": "5cc1c096-98b9-460c-8f1c-1045509ec605", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-14T08:07:40.220Z" + } + }, + "samsungvd.deviceCategory": { + "category": { + "value": null + } + }, + "relativeBrightness": { + "brightnessIntensity": { + "value": 2, + "unit": "level", + "timestamp": "2025-02-11T19:08:25.539Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json index 68070abbfc3..9823a70cb61 100644 --- a/tests/components/smartthings/fixtures/devices/contact_sensor.json +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -42,7 +42,7 @@ "categoryType": "manufacturer" }, { - "name": "ContactSensor", + "name": "GarageDoor", "categoryType": "user" } ] diff --git a/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json new file mode 100644 index 00000000000..c8304e9c6d8 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json @@ -0,0 +1,145 @@ +{ + "items": [ + { + "deviceId": "a3a970ea-e09c-9c04-161b-94c934e21666", + "name": "Samsung AirMonitor", + "label": "\uc5d0\uc5b4\ubaa8\ub2c8\ud130 \ud50c\ub7ec\uc2a4", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-AIRSENSOR-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "33db9e71-abe9-43a0-acd3-3f0927bbe5b7", + "ownerId": "9a1ee192-04ba-46ca-9c3d-196d8dbcf807", + "roomId": "445c006d-1796-4dd6-8308-1c3cd045e8ff", + "deviceTypeName": "x.com.st.d.airqualitysensor", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "dustHealthConcern", + "version": 1 + }, + { + "id": "fineDustHealthConcern", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "veryFineDustHealthConcern", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "carbonDioxideMeasurement", + "version": 1 + }, + { + "id": "carbonDioxideHealthConcern", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.rechargeableBattery", + "version": 1 + }, + { + "id": "samsungce.doNotDisturb", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "AirQualityDetector", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-09T04:05:59.040Z", + "profile": { + "id": "1d34dd9d-6840-3df6-a6d0-5d9f4a4af2e1" + }, + "ocf": { + "ocfDeviceType": "x.com.st.d.airqualitysensor", + "name": "Samsung AirMonitor", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 4.0", + "hwVersion": "Realtek", + "firmwareVersion": "ASM-KR-TP1-22-ACMB1M_16240426", + "vendorId": "DA-AC-AIRSENSOR-01001", + "vendorResourceClientServerVersion": "MediaTek Release 240426", + "lastSignupTime": "2023-12-09T04:05:54.816486Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json new file mode 100644 index 00000000000..44dafc213f0 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json @@ -0,0 +1,217 @@ +{ + "items": [ + { + "deviceId": "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "name": "Samsung Room A/C", + "label": "Office AirFree", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-000003", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "403cd42e-f692-416c-91fd-1883c00e3262", + "ownerId": "dd474e5c-59c0-4bea-a319-ff5287fd3373", + "roomId": "dffe353e-b3c5-4a97-8a8a-797ccc649fab", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.veryFineDustFilter", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.electricHepaFilter", + "version": 1 + }, + { + "id": "custom.doNotDisturbMode", + "version": 1 + }, + { + "id": "custom.periodicSensing", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.ocfResourceVersion", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dustFilterAlarm", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.individualControlLock", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-06-21T13:45:16.238Z", + "profile": { + "id": "cedae6e3-1ec9-37e3-9aba-f717518156b8" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Samsung Room A/C", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ARTIK051_PRAC_20K|10256941|60010534001411014600003200800000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "ARTIK051_PRAC_20K_11230313", + "vendorId": "DA-AC-RAC-000003", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.211222.1", + "lastSignupTime": "2024-06-21T13:45:08.592221Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_cooktop_31001.json b/tests/components/smartthings/fixtures/devices/da_ks_cooktop_31001.json new file mode 100644 index 00000000000..433e45dae7a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_cooktop_31001.json @@ -0,0 +1,277 @@ +{ + "items": [ + { + "deviceId": "808dbd84-f357-47e2-a0cd-3b66fa22d584", + "name": "Builtin Cooktop", + "label": "Induction Hob", + "manufacturerName": "0A4H", + "presentationId": "DA-KS-COOKTOP-31001", + "deviceManufacturerCode": "0A4H", + "locationId": "7d27161a-0ef6-4294-91a0-80054ea5bc59", + "ownerId": "d52fb883-0f76-f4d9-0f6a-7ec2c0987b11", + "roomId": "afe14ff1-d444-420d-a766-4dd52f3e1c71", + "deviceTypeId": "Cooktop", + "deviceTypeName": "Samsung Cooktop", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.userNotification", + "version": 1 + }, + { + "id": "custom.cooktopOperatingState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.errorAndAlarmState", + "version": 1 + }, + { + "id": "samsungce.remoteManagementData", + "version": 1 + }, + { + "id": "samsungce.kidsLockControl", + "version": 1 + }, + { + "id": "samsungce.cooktopFlexZone", + "version": 1 + } + ], + "categories": [ + { + "name": "Cooktop", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-01", + "label": "burner-01", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-02", + "label": "burner-02", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-03", + "label": "burner-03", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-04", + "label": "burner-04", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-05", + "label": "burner-05", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-06", + "label": "burner-06", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hood", + "label": "hood", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "samsungce.hoodFanSpeed", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-25T18:18:23.576Z", + "profile": { + "id": "a99bbcb8-51c9-468d-b9d5-0ce6dca09d5a" + }, + "mqtt": { + "executingLocally": false, + "transferCandidate": false + }, + "type": "MQTT", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json new file mode 100644 index 00000000000..9be5db0bda9 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json @@ -0,0 +1,521 @@ +{ + "items": [ + { + "deviceId": "5758b2ec-563e-f39b-ec39-208e54aabf60", + "name": "Samsung-Refrigerator", + "label": "Frigo", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-01011", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "d91ee683-be36-4124-9200-c0030253fbc2", + "ownerId": "60b5179d-607f-f754-a648-6e1e21aeeb31", + "roomId": "c4f98377-534d-422f-b061-a4f3e281ddf5", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.viewInside", + "version": 1 + }, + { + "id": "samsungce.fridgeWelcomeLighting", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "onedoor", + "label": "onedoor", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-03", + "label": "icemaker-03", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "scale-10", + "label": "scale-10", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "samsungce.weightMeasurementCalibration", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "scale-11", + "label": "scale-11", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-02", + "label": "pantry-02", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-12-01T18:22:14.880Z", + "profile": { + "id": "37c7b355-bdaa-371b-b246-dbdf2a7f9c84" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "Samsung-Refrigerator", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_REF_21K|00156941|00050126001611304100000030010000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "A-RFWW-TP1-22-REV1_20241030", + "vendorId": "DA-REF-NORMAL-01011", + "vendorResourceClientServerVersion": "Realtek Release 3.1.240221", + "lastSignupTime": "2024-12-01T18:22:14.807976528Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_sc_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_sc_000001.json new file mode 100644 index 00000000000..8b501cba9b7 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_sc_000001.json @@ -0,0 +1,172 @@ +{ + "items": [ + { + "deviceId": "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "name": "[airdresser] Samsung", + "label": "AirDresser", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-SC-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "df59873c-4e2c-43ba-bcd4-ade4efb0504a", + "ownerId": "71254e90-c144-45b6-aabe-709f78f48376", + "roomId": "4c9052ba-4430-4cb1-a788-f1e4449c43c9", + "deviceTypeName": "Samsung OCF Steam Closet", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "dryerOperatingState", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "custom.steamClosetOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.steamClosetWrinklePrevent", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.veryFineDustFilter", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.steamClosetDelayEnd", + "version": 1 + }, + { + "id": "samsungce.steamClosetKeepFreshMode", + "version": 1 + }, + { + "id": "samsungce.steamClosetSanitizeMode", + "version": 1 + }, + { + "id": "samsungce.steamClosetAutoCycleLink", + "version": 1 + }, + { + "id": "samsungce.steamClosetCycle", + "version": 1 + }, + { + "id": "samsungce.steamClosetCyclePreset", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "ClothingCareMachine", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-06-23T16:00:40.545Z", + "profile": { + "id": "a3623498-4747-3761-bac1-ba13f437d8ea" + }, + "ocf": { + "ocfDeviceType": "x.com.st.d.steamcloset", + "name": "[airdresser] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 2.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "DA_DF_TP2_20_COMMON_30230807", + "vendorId": "DA-WM-SC-000001", + "vendorResourceClientServerVersion": "MediaTek Release 2.211214.1", + "lastSignupTime": "2023-06-23T16:00:36.793123Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json b/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json new file mode 100644 index 00000000000..0cd42e0e2ce --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json @@ -0,0 +1,155 @@ +{ + "items": [ + { + "deviceId": "5e5b97f3-3094-44e6-abc0-f61283412d6a", + "name": "heatit-zpushwall", + "label": "Livingroom smart switch", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "52933933-7123-3315-a441-92d65df5f031", + "deviceManufacturerCode": "019B-0004-2403", + "locationId": "c85a9f8a-5d2e-4cdd-8bdb-bc49ba4a3544", + "ownerId": "7b68139b-d068-45d8-bf27-961320350024", + "roomId": "56e43461-2f7d-4c43-ba7c-29465f991289", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "battery", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button1", + "label": "button1", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button2", + "label": "button2", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button3", + "label": "button3", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button4", + "label": "button4", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button5", + "label": "button5", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button6", + "label": "button6", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-04T16:51:15.774Z", + "parentDeviceId": "4869d882-e898-40c3-a198-7611b72187a5", + "profile": { + "id": "2d6e59af-63df-3102-8515-66f3d75c9323" + }, + "zwave": { + "networkId": "12", + "driverId": "1d39c140-ce10-490d-bf52-4de7b72caab6", + "executingLocally": true, + "hubId": "4869d882-e898-40c3-a198-7611b72187a5", + "networkSecurityLevel": "ZWAVE_S2_AUTHENTICATED", + "provisioningState": "NONFUNCTIONAL", + "manufacturerId": 411, + "productType": 4, + "productId": 9219 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hw_q80r_soundbar.json b/tests/components/smartthings/fixtures/devices/hw_q80r_soundbar.json new file mode 100644 index 00000000000..5f99cefddcb --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hw_q80r_soundbar.json @@ -0,0 +1,106 @@ +{ + "items": [ + { + "deviceId": "afcf3b91-0000-1111-2222-ddff2a0a6577", + "name": "[AV] Samsung Soundbar Q80R", + "label": "Soundbar", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-NetworkAudio-001S", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c7f8e400-0000-1111-2222-76463f4eb484", + "ownerId": "bd0d9288-0000-1111-2222-68310a42a709", + "roomId": "be09ff51-0000-1111-2222-e48e2dab37fd", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "Soundbar", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "mediaInputSource", + "version": 1 + }, + { + "id": "samsungvd.audioInputSource", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "samsungvd.groupInfo", + "version": 1 + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-10-19T01:35:08Z", + "profile": { + "id": "c1036d88-000-1111-2222-a361463fd53f" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "[AV] Samsung Soundbar Q80R", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "Q80R", + "platformVersion": "Tizen 4.0", + "platformOS": "4.1.10", + "hwVersion": "0-0", + "firmwareVersion": "HW-Q80RWWB-1012.6", + "vendorId": "VD-NetworkAudio-001S", + "vendorResourceClientServerVersion": "1.2", + "locale": "KO", + "lastSignupTime": "2021-01-16T07:05:02.184545Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_sensor_light_2023.json b/tests/components/smartthings/fixtures/devices/vd_sensor_light_2023.json new file mode 100644 index 00000000000..ef1dd2e96bc --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_sensor_light_2023.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "5cc1c096-98b9-460c-8f1c-1045509ec605", + "name": "VD-Sensor.Light-2023", + "label": "Light Sensor - 55\" The Frame", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-Sensor.Light-2023", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "df59873c-4e2c-43ba-bcd4-ade4efb0504a", + "ownerId": "71254e90-c144-45b6-aabe-709f78f48376", + "roomId": "8a4fac38-48d1-4a8c-922b-92620442363b", + "deviceTypeName": "x.com.st.d.sensor.light", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "relativeBrightness", + "version": 1 + }, + { + "id": "samsungvd.deviceCategory", + "version": 1 + } + ], + "categories": [ + { + "name": "LightSensor", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-15T22:21:27.908Z", + "parentDeviceId": "425ac77a-f7c9-a62d-ff12-cdad144952e3", + "profile": { + "id": "5f1633fb-0c63-34d3-9d04-a314d393d225" + }, + "ocf": { + "ocfDeviceType": "x.com.st.d.sensor.light", + "name": "Light Sensor - 55 The Frame", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "QE55LS03DAUXXN", + "platformVersion": "8.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "latest", + "vendorId": "VD-Sensor.Light-2023", + "vendorResourceClientServerVersion": "4.0.26", + "lastSignupTime": "2024-11-15T22:21:27.933740026Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 6223c6c526c..2419a154e05 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.motion', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_motionSensor_motion_motion', 'unit_of_measurement': None, }) # --- @@ -77,7 +77,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.sound', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_soundSensor_sound_sound', 'unit_of_measurement': None, }) # --- @@ -95,7 +95,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-entry] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -108,7 +108,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'entity_id': 'binary_sensor.front_door_open_closed_sensor', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -118,25 +118,216 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage_door', + 'friendly_name': '.Front Door Open/Closed Sensor', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_open_closed_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][binary_sensor.induction_hob_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.induction_hob_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][binary_sensor.induction_hob_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Induction Hob Power', + }), + 'context': , + 'entity_id': 'binary_sensor.induction_hob_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , 'original_icon': None, 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.contact', + 'translation_key': 'door', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_samsungce.doorState_doorState_doorState', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-state] +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': '.Front Door Open/Closed Sensor Door', + 'device_class': 'opening', + 'friendly_name': 'Microwave Door', }), 'context': , - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'entity_id': 'binary_sensor.microwave_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Microwave Power', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -173,7 +364,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.remoteControlEnabled', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -190,6 +381,101 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_samsungce.doorState_doorState_doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -220,7 +506,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.remoteControlEnabled', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -237,6 +523,101 @@ 'state': 'on', }) # --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.vulcan_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.vulcan_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.vulcan_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_samsungce.doorState_doorState_doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Vulcan Door', + }), + 'context': , + 'entity_id': 'binary_sensor.vulcan_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -267,7 +648,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.remoteControlEnabled', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -284,7 +665,7 @@ 'state': 'on', }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-entry] +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_cooler_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -297,7 +678,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.refrigerator_door', + 'entity_id': 'binary_sensor.refrigerator_cooler_door', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -309,23 +690,262 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Door', + 'original_name': 'Cooler door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_door', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_cooler_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_cooler_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Cooler door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_cooler_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_freezer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_freezer_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Freezer door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_door', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_freezer_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_freezer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Freezer door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_freezer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_cooler_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.frigo_cooler_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooler door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_door', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_cooler_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_cooler_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Frigo Cooler door', + }), + 'context': , + 'entity_id': 'binary_sensor.frigo_cooler_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_freezer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.frigo_freezer_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Freezer door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_door', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_freezer_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_freezer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Frigo Freezer door', + }), + 'context': , + 'entity_id': 'binary_sensor.frigo_freezer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dishwasher_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dishwasher_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.contact', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-state] +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Refrigerator Door', + 'device_class': 'power', + 'friendly_name': 'Dishwasher Power', }), 'context': , - 'entity_id': 'binary_sensor.refrigerator_door', + 'entity_id': 'binary_sensor.dishwasher_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -362,7 +982,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.remoteControlEnabled', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -379,6 +999,243 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.airdresser_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.airdresser_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.airdresser_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'AirDresser Power', + }), + 'context': , + 'entity_id': 'binary_sensor.airdresser_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.airdresser_remote_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.airdresser_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dryer_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dryer_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dryer Power', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -409,7 +1266,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.remoteControlEnabled', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -426,6 +1283,148 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_wrinkle_prevent_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dryer_wrinkle_prevent_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wrinkle prevent active', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_wrinkle_prevent_active', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_custom.dryerWrinklePrevent_operatingState_operatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_wrinkle_prevent_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Wrinkle prevent active', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_wrinkle_prevent_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.seca_roupa_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.seca_roupa_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Seca-Roupa Power', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -456,7 +1455,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.remoteControlEnabled', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -473,6 +1472,148 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_wrinkle_prevent_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.seca_roupa_wrinkle_prevent_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wrinkle prevent active', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_wrinkle_prevent_active', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_custom.dryerWrinklePrevent_operatingState_operatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_wrinkle_prevent_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Wrinkle prevent active', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_wrinkle_prevent_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washer Power', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -503,7 +1644,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.remoteControlEnabled', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -520,6 +1661,101 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washing_machine_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washing_machine_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washing Machine Power', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -550,7 +1786,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.remoteControlEnabled', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -597,7 +1833,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.motion', + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_motionSensor_motion_motion', 'unit_of_measurement': None, }) # --- @@ -645,7 +1881,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.presence', + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_presenceSensor_presence_presence', 'unit_of_measurement': None, }) # --- @@ -693,7 +1929,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '184c67cc-69e2-44b6-8f73-55c963068ad9.presence', + 'unique_id': '184c67cc-69e2-44b6-8f73-55c963068ad9_main_presenceSensor_presence_presence', 'unit_of_measurement': None, }) # --- @@ -711,6 +1947,54 @@ 'state': 'on', }) # --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Deck Door', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -741,7 +2025,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'acceleration', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.acceleration', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_accelerationSensor_acceleration_acceleration', 'unit_of_measurement': None, }) # --- @@ -759,102 +2043,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.deck_door_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Door', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.contact', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Deck Door Door', - }), - 'context': , - 'entity_id': 'binary_sensor.deck_door_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.volvo_valve', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Valve', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'valve', - 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3.valve', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'opening', - 'friendly_name': 'volvo Valve', - }), - 'context': , - 'entity_id': 'binary_sensor.volvo_valve', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -885,7 +2073,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.water', + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116_main_waterSensor_water_water', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_button.ambr b/tests/components/smartthings/snapshots/test_button.ambr new file mode 100644 index 00000000000..2c9dbd008af --- /dev/null +++ b/tests/components/smartthings/snapshots/test_button.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_all_entities[da_ks_microwave_0101x][button.microwave_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.microwave_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][button.microwave_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Stop', + }), + 'context': , + 'entity_id': 'button.microwave_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][button.oven_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][button.vulcan_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.vulcan_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][button.vulcan_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Stop', + }), + 'context': , + 'entity_id': 'button.vulcan_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][button.refrigerator_reset_water_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.refrigerator_reset_water_filter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset water filter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_water_filter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_custom.waterFilter_resetWaterFilter', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][button.refrigerator_reset_water_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Reset water filter', + }), + 'context': , + 'entity_id': 'button.refrigerator_reset_water_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 893093ee2aa..633b02568fc 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -36,7 +36,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551', + 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551_main', 'unit_of_measurement': None, }) # --- @@ -70,6 +70,7 @@ 'area_id': None, 'capabilities': dict({ 'hvac_modes': list([ + , , ]), 'max_temp': 35, @@ -99,7 +100,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main', 'unit_of_measurement': None, }) # --- @@ -109,6 +110,7 @@ 'current_temperature': 23.9, 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer', 'hvac_modes': list([ + , , ]), 'max_temp': 35, @@ -178,7 +180,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main', 'unit_of_measurement': None, }) # --- @@ -209,7 +211,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': None, + 'preset_mode': 'windFree', 'preset_modes': list([ 'windFree', ]), @@ -226,6 +228,112 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ac_rac_000003][climate.office_airfree-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'windFree', + ]), + 'swing_modes': list([ + 'off', + 'both', + 'vertical', + 'horizontal', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.office_airfree', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_000003][climate.office_airfree-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26, + 'drlc_status_duration': 0, + 'drlc_status_override': False, + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'Office AirFree', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'windFree', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'both', + 'vertical', + 'horizontal', + ]), + 'temperature': 24, + }), + 'context': , + 'entity_id': 'climate.office_airfree', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- # name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -283,7 +391,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main', 'unit_of_measurement': None, }) # --- @@ -383,7 +491,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'F8042E25-0E53-0000-0000-000000000000', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main', 'unit_of_measurement': None, }) # --- @@ -431,6 +539,7 @@ 'auto', ]), 'hvac_modes': list([ + , , , ]), @@ -461,7 +570,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main', 'unit_of_measurement': None, }) # --- @@ -478,6 +587,7 @@ 'friendly_name': 'Main Floor', 'hvac_action': , 'hvac_modes': list([ + , , , ]), @@ -532,7 +642,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db', + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main', 'unit_of_measurement': None, }) # --- @@ -595,7 +705,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main', 'unit_of_measurement': None, }) # --- @@ -628,6 +738,7 @@ 'area_id': None, 'capabilities': dict({ 'hvac_modes': list([ + , , ]), 'max_temp': 35, @@ -657,7 +768,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '69a271f6-6537-4982-8cd9-979866872692', + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main', 'unit_of_measurement': None, }) # --- @@ -668,6 +779,7 @@ 'friendly_name': 'Hall thermostat', 'hvac_action': , 'hvac_modes': list([ + , , ]), 'max_temp': 35, @@ -695,6 +807,7 @@ 'on', ]), 'hvac_modes': list([ + , ]), 'max_temp': 35.0, 'min_temp': 7.0, @@ -723,7 +836,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6', + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main', 'unit_of_measurement': None, }) # --- @@ -738,6 +851,7 @@ 'friendly_name': 'asd', 'hvac_action': , 'hvac_modes': list([ + , ]), 'max_temp': 35.0, 'min_temp': 7.0, diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index 6877a8ccc01..4b5cf705665 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '571af102-15db-4030-b76b-245a691f74a5', + 'unique_id': '571af102-15db-4030-b76b-245a691f74a5_main', 'unit_of_measurement': None, }) # --- @@ -79,7 +79,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638', + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr index b9847bf9746..dc7f699de27 100644 --- a/tests/components/smartthings/snapshots/test_diagnostics.ambr +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -1065,7 +1065,7 @@ 'custom.airConditionerOptionalMode': dict({ 'acOptionalMode': dict({ 'timestamp': '2025-02-09T09:14:39.642Z', - 'value': 'off', + 'value': 'windFree', }), 'supportedAcOptionalMode': dict({ 'timestamp': '2024-09-10T10:26:28.781Z', diff --git a/tests/components/smartthings/snapshots/test_event.ambr b/tests/components/smartthings/snapshots/test_event.ambr new file mode 100644 index 00000000000..79c57df5fd7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_event.ambr @@ -0,0 +1,361 @@ +# serializer version: 1 +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button1', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button1_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button1', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button2', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button2_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button2', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button3', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button3_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button3', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button4', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button4_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button4', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button5_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button5', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button6', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button6_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button6', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr index 40ab7b12267..1196118b3b5 100644 --- a/tests/components/smartthings/snapshots/test_fan.ambr +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -37,7 +37,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'f1af21a2-d5a1-437c-b10a-b34a87394b71', + 'unique_id': 'f1af21a2-d5a1-437c-b10a-b34a87394b71_main', 'unit_of_measurement': None, }) # --- @@ -97,7 +97,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '6d95a8b7-4ee3-429a-a13a-00ec9354170c', + 'unique_id': '6d95a8b7-4ee3-429a-a13a-00ec9354170c_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index e62c34cd11c..8ec97af7d84 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -299,6 +299,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ac_airsensor_01001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a3a970ea-e09c-9c04-161b-94c934e21666', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ASM-KR-TP1-22-ACMB1M', + 'model_id': None, + 'name': '에어모니터 플러스', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'ASM-KR-TP1-22-ACMB1M_16240426', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ac_rac_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', @@ -332,6 +365,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ac_rac_000003] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'c76d6f38-1b7f-13dd-37b5-db18d5272783', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ARTIK051_PRAC_20K', + 'model_id': None, + 'name': 'Office AirFree', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'ARTIK051_PRAC_20K_11230313', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ac_rac_01001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', @@ -398,6 +464,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ks_cooktop_31001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '808dbd84-f357-47e2-a0cd-3b66fa22d584', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Induction Hob', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', @@ -530,6 +629,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ref_normal_01011] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '5758b2ec-563e-f39b-ec39-208e54aabf60', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_REF_21K', + 'model_id': None, + 'name': 'Frigo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'A-RFWW-TP1-22-REV1_20241030', + 'via_device_id': None, + }) +# --- # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', @@ -629,6 +761,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_wm_sc_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_DF_TP2_20_COMMON', + 'model_id': None, + 'name': 'AirDresser', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_DF_TP2_20_COMMON_30230807', + 'via_device_id': None, + }) +# --- # name: test_devices[da_wm_wd_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', @@ -992,6 +1157,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[heatit_zpushwall] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '5e5b97f3-3094-44e6-abc0-f61283412d6a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Livingroom smart switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[heatit_ztrm3_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1091,6 +1289,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[hw_q80r_soundbar] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0-0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'afcf3b91-0000-1111-2222-ddff2a0a6577', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'Q80R', + 'model_id': None, + 'name': 'Soundbar', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'HW-Q80RWWB-1012.6', + 'via_device_id': None, + }) +# --- # name: test_devices[ikea_kadrilj] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1388,6 +1619,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[vd_sensor_light_2023] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '5cc1c096-98b9-460c-8f1c-1045509ec605', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'QE55LS03DAUXXN', + 'model_id': None, + 'name': 'Light Sensor - 55" The Frame', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'latest', + 'via_device_id': None, + }) +# --- # name: test_devices[vd_stv_2017_k] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', @@ -1558,7 +1822,7 @@ 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , - 'configuration_url': None, + 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( 'mac', diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr index f1f2b92de77..6826a555f6a 100644 --- a/tests/components/smartthings/snapshots/test_light.ambr +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -37,7 +37,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '7c16163e-c94e-482f-95f6-139ae0cd9d5e', + 'unique_id': '7c16163e-c94e-482f-95f6-139ae0cd9d5e_main', 'unit_of_measurement': None, }) # --- @@ -103,7 +103,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad', + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main', 'unit_of_measurement': None, }) # --- @@ -160,7 +160,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', + 'unique_id': 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298_main', 'unit_of_measurement': None, }) # --- @@ -221,7 +221,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '440063de-a200-40b5-8a6b-f3399eaa0370', + 'unique_id': '440063de-a200-40b5-8a6b-f3399eaa0370_main', 'unit_of_measurement': None, }) # --- @@ -302,7 +302,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'cb958955-b015-498c-9e62-fc0c51abd054', + 'unique_id': 'cb958955-b015-498c-9e62-fc0c51abd054_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_lock.ambr b/tests/components/smartthings/snapshots/test_lock.ambr index 2cf9688c3dd..325ce0cc677 100644 --- a/tests/components/smartthings/snapshots/test_lock.ambr +++ b/tests/components/smartthings/snapshots/test_lock.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158', + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_media_player.ambr b/tests/components/smartthings/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..83f9d19b9fa --- /dev/null +++ b/tests/components/smartthings/snapshots/test_media_player.ambr @@ -0,0 +1,298 @@ +# serializer version: 1 +# name: test_all_entities[hw_q80r_soundbar][media_player.soundbar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'wifi', + 'bluetooth', + 'HDMI1', + 'HDMI2', + 'digital', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.soundbar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hw_q80r_soundbar][media_player.soundbar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Soundbar', + 'is_volume_muted': False, + 'media_artist': 'Rick Astley', + 'media_title': 'Never Gonna Give You Up', + 'source': 'wifi', + 'source_list': list([ + 'wifi', + 'bluetooth', + 'HDMI1', + 'HDMI2', + 'digital', + ]), + 'supported_features': , + 'volume_level': 0.01, + }), + 'context': , + 'entity_id': 'media_player.soundbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][media_player.galaxy_home_mini-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.galaxy_home_mini', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][media_player.galaxy_home_mini-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Galaxy Home Mini', + 'is_volume_muted': False, + 'repeat': , + 'shuffle': False, + 'supported_features': , + 'volume_level': 0.52, + }), + 'context': , + 'entity_id': 'media_player.galaxy_home_mini', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[sonos_player][media_player.elliots_rum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.elliots_rum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sonos_player][media_player.elliots_rum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Elliots Rum', + 'is_volume_muted': False, + 'media_artist': 'David Guetta', + 'media_title': 'Forever Young', + 'supported_features': , + 'volume_level': 0.15, + }), + 'context': , + 'entity_id': 'media_player.elliots_rum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][media_player.soundbar_living-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.soundbar_living', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_002s][media_player.soundbar_living-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Soundbar Living', + 'is_volume_muted': False, + 'media_artist': '', + 'media_title': '', + 'source': 'HDMI1', + 'supported_features': , + 'volume_level': 0.17, + }), + 'context': , + 'entity_id': 'media_player.soundbar_living', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][media_player.tv_samsung_8_series_49-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'digitalTv', + 'HDMI1', + 'HDMI4', + 'HDMI4', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tv_samsung_8_series_49', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][media_player.tv_samsung_8_series_49-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': '[TV] Samsung 8 Series (49)', + 'is_volume_muted': True, + 'source': 'HDMI1', + 'source_list': list([ + 'digitalTv', + 'HDMI1', + 'HDMI4', + 'HDMI4', + ]), + 'supported_features': , + 'volume_level': 0.13, + }), + 'context': , + 'entity_id': 'media_player.tv_samsung_8_series_49', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr new file mode 100644 index 00000000000..66aade5b958 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.washer_rinse_cycles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rinse cycles', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_rinse_cycles', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles', + 'unit_of_measurement': 'cycles', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Rinse cycles', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cycles', + }), + 'context': , + 'entity_id': 'number.washer_rinse_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][number.washing_machine_rinse_cycles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.washing_machine_rinse_cycles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rinse cycles', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_rinse_cycles', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles', + 'unit_of_measurement': 'cycles', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][number.washing_machine_rinse_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Rinse cycles', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cycles', + }), + 'context': , + 'entity_id': 'number.washing_machine_rinse_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr new file mode 100644 index 00000000000..06185e09547 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -0,0 +1,349 @@ +# serializer version: 1 +# name: test_all_entities[da_wm_dw_000001][select.dishwasher-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dishwasher', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][select.dishwasher-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.dishwasher', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][select.airdresser-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.airdresser', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][select.airdresser-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.airdresser', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][select.dryer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dryer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][select.dryer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.dryer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][select.seca_roupa-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.seca_roupa', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][select.seca_roupa-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.seca_roupa', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][select.washer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.washer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][select.washer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.washer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][select.washing_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.washing_machine', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][select.washing_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.washing_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 954bcc5c281..8ace345be18 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.energy', + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_energyMeter_energy_energy', 'unit_of_measurement': 'kWh', }) # --- @@ -83,7 +83,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.power', + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_powerMeter_power_power', 'unit_of_measurement': 'W', }) # --- @@ -135,7 +135,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.voltage', + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_voltageMeasurement_voltage_voltage', 'unit_of_measurement': None, }) # --- @@ -186,7 +186,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551.temperature', + 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -238,7 +238,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.energy', + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b_main_energyMeter_energy_energy', 'unit_of_measurement': 'kWh', }) # --- @@ -290,7 +290,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.power', + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b_main_powerMeter_power_power', 'unit_of_measurement': 'W', }) # --- @@ -340,7 +340,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5.battery', + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -391,7 +391,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5.temperature', + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -448,7 +448,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'alarm', - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.alarm', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_alarm_alarm_alarm', 'unit_of_measurement': None, }) # --- @@ -502,7 +502,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.battery', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -553,7 +553,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad.power', + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main_powerMeter_power_power', 'unit_of_measurement': 'W', }) # --- @@ -603,7 +603,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.battery', + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -654,7 +654,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.temperature', + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -674,6 +674,416 @@ 'state': '15.0', }) # --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_air_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_airQualitySensor_airQuality_airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '에어모니터 플러스 Air quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_carbonDioxideMeasurement_carbonDioxide_carbonDioxide', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': '에어모니터 플러스 Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1045', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': '에어모니터 플러스 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_odor_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_odor_sensor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Odor sensor', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'odor_sensor', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_odorSensor_odorLevel_odorLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_odor_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '에어모니터 플러스 Odor sensor', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_odor_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_veryFineDustSensor_veryFineDustLevel_veryFineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': '에어모니터 플러스 PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_dustSensor_dustLevel_dustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': '에어모니터 플러스 PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_dustSensor_fineDustLevel_fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': '에어모니터 플러스 PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '에어모니터 플러스 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.0', + }) +# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -709,7 +1119,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energy_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -764,7 +1174,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -819,7 +1229,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -871,7 +1281,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -926,7 +1336,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.power_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -983,7 +1393,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.powerEnergy_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -1035,7 +1445,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.temperature', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -1085,7 +1495,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.volume', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- @@ -1103,6 +1513,435 @@ 'state': '100', }) # --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '602.171', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Office AirFree Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_airfree_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Office AirFree Power', + 'power_consumption_end': '2025-03-27T05:40:02Z', + 'power_consumption_start': '2025-03-27T05:29:22Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office AirFree Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_audioVolume_volume_volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office AirFree Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_airfree_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1138,7 +1977,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energy_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -1193,7 +2032,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -1248,7 +2087,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -1300,7 +2139,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -1355,7 +2194,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.power_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -1412,7 +2251,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.powerEnergy_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -1464,7 +2303,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.temperature', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -1514,7 +2353,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.volume', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- @@ -1564,7 +2403,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_quality', - 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.airQuality', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_airQualitySensor_airQuality_airQuality', 'unit_of_measurement': 'CAQI', }) # --- @@ -1615,7 +2454,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.dustLevel', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_dustSensor_dustLevel_dustLevel', 'unit_of_measurement': 'µg/m³', }) # --- @@ -1667,7 +2506,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.fineDustLevel', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_dustSensor_fineDustLevel_fineDustLevel', 'unit_of_measurement': 'µg/m³', }) # --- @@ -1719,7 +2558,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.temperature', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -1769,7 +2608,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -1837,7 +2676,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_job_state', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_ovenJobState_ovenJobState', 'unit_of_measurement': None, }) # --- @@ -1910,7 +2749,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_machine_state', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -1991,7 +2830,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_mode', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenMode', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenMode_ovenMode_ovenMode', 'unit_of_measurement': None, }) # --- @@ -2066,7 +2905,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_setpoint', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', 'unit_of_measurement': , }) # --- @@ -2117,7 +2956,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.temperature', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -2167,7 +3006,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.completionTime', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -2235,7 +3074,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_job_state', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenJobState', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_ovenJobState_ovenJobState', 'unit_of_measurement': None, }) # --- @@ -2308,7 +3147,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_machine_state', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.machineState', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -2389,7 +3228,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_mode', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenMode', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenMode_ovenMode_ovenMode', 'unit_of_measurement': None, }) # --- @@ -2464,7 +3303,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_setpoint', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenSetpoint', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', 'unit_of_measurement': , }) # --- @@ -2515,7 +3354,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.temperature', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -2565,7 +3404,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.completionTime', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -2633,7 +3472,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_job_state', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenJobState', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_ovenJobState_ovenJobState', 'unit_of_measurement': None, }) # --- @@ -2706,7 +3545,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_machine_state', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.machineState', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -2787,7 +3626,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_mode', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenMode', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenMode_ovenMode_ovenMode', 'unit_of_measurement': None, }) # --- @@ -2862,7 +3701,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_setpoint', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenSetpoint', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', 'unit_of_measurement': , }) # --- @@ -2913,7 +3752,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.temperature', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -2968,7 +3807,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energy_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -3023,7 +3862,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -3078,7 +3917,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -3133,7 +3972,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.power_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -3190,7 +4029,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.powerEnergy_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -3210,6 +4049,283 @@ 'state': '0.0135559777781698', }) # --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66.571', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.019', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Frigo Power', + 'power_consumption_end': '2025-03-30T18:38:18Z', + 'power_consumption_start': '2025-03-30T18:21:37Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0189117822202047', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3240,7 +4356,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.battery', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -3298,7 +4414,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_cleaning_mode', - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerCleaningMode', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerCleaningMode_robotCleanerCleaningMode_robotCleanerCleaningMode', 'unit_of_measurement': None, }) # --- @@ -3367,7 +4483,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_movement', - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerMovement', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerMovement_robotCleanerMovement_robotCleanerMovement', 'unit_of_measurement': None, }) # --- @@ -3434,7 +4550,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_turbo_mode', - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerTurboMode', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerTurboMode_robotCleanerTurboMode_robotCleanerTurboMode', 'unit_of_measurement': None, }) # --- @@ -3488,7 +4604,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thermostat_cooling_setpoint', - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.coolingSetpoint', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', 'unit_of_measurement': , }) # --- @@ -3542,7 +4658,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.energy_meter', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -3597,7 +4713,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.deltaEnergy_meter', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -3652,7 +4768,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.energySaved_meter', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -3707,7 +4823,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.power_meter', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -3764,7 +4880,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.powerEnergy_meter', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -3816,7 +4932,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.temperature', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -3866,7 +4982,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -3919,7 +5035,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energy_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -3974,7 +5090,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -4029,7 +5145,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -4092,7 +5208,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dishwasher_job_state', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_dishwasherJobState_dishwasherJobState', 'unit_of_measurement': None, }) # --- @@ -4158,7 +5274,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dishwasher_machine_state', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -4216,7 +5332,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.power_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -4273,7 +5389,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.powerEnergy_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -4293,6 +5409,473 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_completionTime_completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'AirDresser Completion time', + }), + 'context': , + 'entity_id': 'sensor.airdresser_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-11T09:00:17+00:00', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '207.5', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_job_state', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_dryerJobState_dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'AirDresser Job state', + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'context': , + 'entity_id': 'sensor.airdresser_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_machine_state', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'AirDresser Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.airdresser_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'AirDresser Power', + 'power_consumption_end': '2025-02-11T08:21:17Z', + 'power_consumption_start': '2025-02-10T22:51:59Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[da_wm_wd_000001][sensor.dryer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4323,7 +5906,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -4376,7 +5959,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energy_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -4431,7 +6014,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -4486,7 +6069,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -4554,7 +6137,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_job_state', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_dryerJobState_dryerJobState', 'unit_of_measurement': None, }) # --- @@ -4625,7 +6208,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_machine_state', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -4683,7 +6266,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.power_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -4740,7 +6323,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.powerEnergy_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -4790,7 +6373,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.completionTime', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -4843,7 +6426,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.energy_meter', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -4898,7 +6481,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.deltaEnergy_meter', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -4953,7 +6536,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.energySaved_meter', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -5021,7 +6604,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_job_state', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.dryerJobState', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_dryerJobState_dryerJobState', 'unit_of_measurement': None, }) # --- @@ -5092,7 +6675,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_machine_state', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.machineState', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -5150,7 +6733,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.power_meter', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -5207,7 +6790,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.powerEnergy_meter', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -5257,7 +6840,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -5310,7 +6893,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energy_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -5365,7 +6948,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -5420,7 +7003,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -5489,7 +7072,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_job_state', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_washerJobState_washerJobState', 'unit_of_measurement': None, }) # --- @@ -5561,7 +7144,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_machine_state', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -5619,7 +7202,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.power_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -5676,7 +7259,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.powerEnergy_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -5726,7 +7309,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.completionTime', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -5779,7 +7362,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.energy_meter', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -5834,7 +7417,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.deltaEnergy_meter', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -5889,7 +7472,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.energySaved_meter', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -5958,7 +7541,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_job_state', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.washerJobState', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_washerJobState_washerJobState', 'unit_of_measurement': None, }) # --- @@ -6030,7 +7613,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_machine_state', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.machineState', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -6088,7 +7671,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.power_meter', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -6145,7 +7728,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.powerEnergy_meter', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -6197,7 +7780,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.temperature', + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -6249,7 +7832,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.humidity', + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -6301,7 +7884,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.temperature', + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -6353,7 +7936,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db.humidity', + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -6405,7 +7988,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db.temperature', + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': None, }) # --- @@ -6456,7 +8039,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'link_quality', - 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.lqi', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_signalStrength_lqi_lqi', 'unit_of_measurement': None, }) # --- @@ -6506,7 +8089,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.rssi', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_signalStrength_rssi_rssi', 'unit_of_measurement': 'dBm', }) # --- @@ -6558,7 +8141,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.temperature', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -6578,6 +8161,55 @@ 'state': '21.0', }) # --- +# name: test_all_entities[heatit_zpushwall][sensor.livingroom_smart_switch_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.livingroom_smart_switch_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_main_battery_battery_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[heatit_zpushwall][sensor.livingroom_smart_switch_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Livingroom smart switch Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.livingroom_smart_switch_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6610,7 +8242,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.energy', + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_energyMeter_energy_energy', 'unit_of_measurement': 'kWh', }) # --- @@ -6662,7 +8294,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.power', + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_powerMeter_power_power', 'unit_of_measurement': 'W', }) # --- @@ -6714,7 +8346,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.temperature', + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -6764,7 +8396,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638.battery', + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -6783,261 +8415,6 @@ 'state': '37', }) # --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.galaxy_home_mini_media_input_source', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media input source', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_input_source', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.inputSource', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Galaxy Home Mini Media input source', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_input_source', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.galaxy_home_mini_media_playback_repeat', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Media playback repeat', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_repeat', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackRepeatMode', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Galaxy Home Mini Media playback repeat', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_playback_repeat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.galaxy_home_mini_media_playback_shuffle', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Media playback shuffle', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_shuffle', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackShuffle', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Galaxy Home Mini Media playback shuffle', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_playback_shuffle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'disabled', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Galaxy Home Mini Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stopped', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.galaxy_home_mini_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Galaxy Home Mini Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '52', - }) -# --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7068,7 +8445,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.battery', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -7119,7 +8496,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.temperature', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -7169,7 +8546,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'x_coordinate', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c X Coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_x_coordinate', 'unit_of_measurement': None, }) # --- @@ -7216,7 +8593,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'y_coordinate', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_y_coordinate', 'unit_of_measurement': None, }) # --- @@ -7263,7 +8640,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'z_coordinate', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Z Coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_z_coordinate', 'unit_of_measurement': None, }) # --- @@ -7310,7 +8687,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_conditioner_mode', - 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.airConditionerMode', + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_airConditionerMode_airConditionerMode_airConditionerMode', 'unit_of_measurement': None, }) # --- @@ -7357,7 +8734,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thermostat_cooling_setpoint', - 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.coolingSetpoint', + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', 'unit_of_measurement': , }) # --- @@ -7376,119 +8753,6 @@ 'state': '20', }) # --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.elliots_rum_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Elliots Rum Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.elliots_rum_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'playing', - }) -# --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.elliots_rum_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Elliots Rum Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.elliots_rum_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '15', - }) -# --- # name: test_all_entities[tplink_p110][sensor.spulmaschine_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7524,7 +8788,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1.energy_meter', + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -7579,7 +8843,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1.deltaEnergy_meter', + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -7599,20 +8863,13 @@ 'state': '0.0', }) # --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-entry] +# name: test_all_entities[vd_sensor_light_2023][sensor.light_sensor_55_the_frame_brightness_intensity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -7621,63 +8878,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.soundbar_living_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Soundbar Living Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.soundbar_living_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stopped', - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.soundbar_living_volume', + 'entity_id': 'sensor.light_sensor_55_the_frame_brightness_intensity', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -7689,153 +8890,28 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Volume', + 'original_name': 'Brightness intensity', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.volume', - 'unit_of_measurement': '%', + 'translation_key': 'brightness_intensity', + 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605_main_relativeBrightness_brightnessIntensity_brightnessIntensity', + 'unit_of_measurement': 'level', }) # --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-state] +# name: test_all_entities[vd_sensor_light_2023][sensor.light_sensor_55_the_frame_brightness_intensity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Living Volume', - 'unit_of_measurement': '%', + 'friendly_name': 'Light Sensor - 55" The Frame Brightness intensity', + 'state_class': , + 'unit_of_measurement': 'level', }), 'context': , - 'entity_id': 'sensor.soundbar_living_volume', + 'entity_id': 'sensor.light_sensor_55_the_frame_brightness_intensity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '17', - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'digitaltv', - 'hdmi1', - 'hdmi4', - 'hdmi4', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media input source', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_input_source', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.inputSource', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': '[TV] Samsung 8 Series (49) Media input source', - 'options': list([ - 'digitaltv', - 'hdmi1', - 'hdmi4', - 'hdmi4', - ]), - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'hdmi1', - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': '[TV] Samsung 8 Series (49) Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': '2', }) # --- # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-entry] @@ -7868,7 +8944,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'tv_channel', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannel', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_tvChannel_tvChannel_tvChannel', 'unit_of_measurement': None, }) # --- @@ -7915,7 +8991,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'tv_channel_name', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannelName', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_tvChannel_tvChannelName_tvChannelName', 'unit_of_measurement': None, }) # --- @@ -7932,54 +9008,6 @@ 'state': '', }) # --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.tv_samsung_8_series_49_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13', - }) -# --- # name: test_all_entities[virtual_thermostat][sensor.asd_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8010,7 +9038,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.battery', + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -8061,7 +9089,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.temperature', + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -8111,7 +9139,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.battery', + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -8160,7 +9188,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158.battery', + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 08db5ffc244..d14d4d02aa4 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -46,7 +46,7 @@ 'state': 'on', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-entry] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,7 +59,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.microwave', + 'entity_id': 'switch.refrigerator_ice_maker', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -71,22 +71,22 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Ice maker', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', + 'translation_key': 'ice_maker', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_icemaker_switch_switch_switch', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-state] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave', + 'friendly_name': 'Refrigerator Ice maker', }), 'context': , - 'entity_id': 'switch.microwave', + 'entity_id': 'switch.refrigerator_ice_maker', 'last_changed': , 'last_reported': , 'last_updated': , @@ -123,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -170,7 +170,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -187,7 +187,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-entry] +# name: test_all_entities[da_wm_wd_000001][switch.dryer_wrinkle_prevent-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -200,7 +200,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.dishwasher', + 'entity_id': 'switch.dryer_wrinkle_prevent', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -212,29 +212,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Wrinkle prevent', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', + 'translation_key': 'wrinkle_prevent', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_custom.dryerWrinklePrevent_dryerWrinklePrevent_dryerWrinklePrevent', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-state] +# name: test_all_entities[da_wm_wd_000001][switch.dryer_wrinkle_prevent-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher', + 'friendly_name': 'Dryer Wrinkle prevent', }), 'context': , - 'entity_id': 'switch.dishwasher', + 'entity_id': 'switch.dryer_wrinkle_prevent', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_wm_wd_000001][switch.dryer-entry] +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa_wrinkle_prevent-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -247,7 +247,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.dryer', + 'entity_id': 'switch.seca_roupa_wrinkle_prevent', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -259,29 +259,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Wrinkle prevent', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b', + 'translation_key': 'wrinkle_prevent', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_custom.dryerWrinklePrevent_dryerWrinklePrevent_dryerWrinklePrevent', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_wd_000001][switch.dryer-state] +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa_wrinkle_prevent-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dryer', + 'friendly_name': 'Seca-Roupa Wrinkle prevent', }), 'context': , - 'entity_id': 'switch.dryer', + 'entity_id': 'switch.seca_roupa_wrinkle_prevent', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-entry] +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine_bubble_soak-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -294,7 +294,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.seca_roupa', + 'entity_id': 'switch.washing_machine_bubble_soak', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -306,122 +306,28 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Bubble Soak', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd', + 'translation_key': 'bubble_soak', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_samsungce.washerBubbleSoak_status_status', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-state] +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine_bubble_soak-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Seca-Roupa', + 'friendly_name': 'Washing Machine Bubble Soak', }), 'context': , - 'entity_id': 'switch.seca_roupa', + 'entity_id': 'switch.washing_machine_bubble_soak', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_wm_wm_000001][switch.washer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.washer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][switch.washer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washer', - }), - 'context': , - 'entity_id': 'switch.washer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.washing_machine', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washing Machine', - }), - 'context': , - 'entity_id': 'switch.washing_machine', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_all_entities[generic_ef00_v1][switch.thermostat_kuche-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -452,7 +358,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -499,7 +405,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5', + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -546,7 +452,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398', + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -593,7 +499,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1', + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -610,7 +516,7 @@ 'state': 'on', }) # --- -# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-entry] +# name: test_all_entities[vd_sensor_light_2023][switch.light_sensor_55_the_frame-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -623,7 +529,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.soundbar_living', + 'entity_id': 'switch.light_sensor_55_the_frame', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -640,67 +546,20 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac', + 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-state] +# name: test_all_entities[vd_sensor_light_2023][switch.light_sensor_55_the_frame-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Living', + 'friendly_name': 'Light Sensor - 55" The Frame', }), 'context': , - 'entity_id': 'switch.soundbar_living', + 'entity_id': 'switch.light_sensor_55_the_frame', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.tv_samsung_8_series_49', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49)', - }), - 'context': , - 'entity_id': 'switch.tv_samsung_8_series_49', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- diff --git a/tests/components/smartthings/snapshots/test_update.ambr b/tests/components/smartthings/snapshots/test_update.ambr new file mode 100644 index 00000000000..c27a0b9f5fc --- /dev/null +++ b/tests/components/smartthings/snapshots/test_update.ambr @@ -0,0 +1,421 @@ +# serializer version: 1 +# name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.radiator_thermostat_ii_m_wohnzimmer_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Firmware', + 'in_progress': False, + 'installed_version': '2.00.09 (20009)', + 'latest_version': '2.00.09 (20009)', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.radiator_thermostat_ii_m_wohnzimmer_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[centralite][update.dimmer_debian_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.dimmer_debian_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[centralite][update.dimmer_debian_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Dimmer Debian Firmware', + 'in_progress': False, + 'installed_version': '16015010', + 'latest_version': '16015010', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.dimmer_debian_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[contact_sensor][update.front_door_open_closed_sensor_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.front_door_open_closed_sensor_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[contact_sensor][update.front_door_open_closed_sensor_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': '.Front Door Open/Closed Sensor Firmware', + 'in_progress': False, + 'installed_version': '00000103', + 'latest_version': '00000104', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.front_door_open_closed_sensor_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[ikea_kadrilj][update.kitchen_ikea_kadrilj_window_blind_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.kitchen_ikea_kadrilj_window_blind_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ikea_kadrilj][update.kitchen_ikea_kadrilj_window_blind_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind Firmware', + 'in_progress': False, + 'installed_version': '22007631', + 'latest_version': '22007631', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.kitchen_ikea_kadrilj_window_blind_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[multipurpose_sensor][update.deck_door_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.deck_door_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][update.deck_door_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Deck Door Firmware', + 'in_progress': False, + 'installed_version': '0000001B', + 'latest_version': '0000001B', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.deck_door_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[smart_plug][update.arlo_beta_basestation_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.arlo_beta_basestation_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[smart_plug][update.arlo_beta_basestation_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Arlo Beta Basestation Firmware', + 'in_progress': False, + 'installed_version': '00102101', + 'latest_version': '00102101', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.arlo_beta_basestation_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][update.basement_door_lock_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.basement_door_lock_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][update.basement_door_lock_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Basement Door Lock Firmware', + 'in_progress': False, + 'installed_version': '00840847', + 'latest_version': '00840847', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.basement_door_lock_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_valve.ambr b/tests/components/smartthings/snapshots/test_valve.ambr index bdb61187e3a..f82155c8499 100644 --- a/tests/components/smartthings/snapshots/test_valve.ambr +++ b/tests/components/smartthings/snapshots/test_valve.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3', + 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 4d58b5ddd48..9f9d8d66317 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -8,8 +8,9 @@ from syrupy import SnapshotAssertion from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.script import scripts_with_entity -from homeassistant.components.smartthings import DOMAIN +from homeassistant.components.smartthings import DOMAIN, MAIN from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir @@ -44,7 +45,7 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_OFF + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_OFF await trigger_update( hass, @@ -53,28 +54,59 @@ async def test_state_update( Capability.CONTACT_SENSOR, Attribute.CONTACT, "open", + component="cooler", ) - assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_ON + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_ON -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) -async def test_create_issue( +@pytest.mark.parametrize( + ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), + [ + ( + "virtual_valve", + f"612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_{MAIN}_{Capability.VALVE}_{Attribute.VALVE}_{Attribute.VALVE}", + "volvo_valve", + "valve", + "binary_sensor.volvo_valve", + ), + ( + "da_ref_normal_000001", + f"7db87911-7dce-1cf2-7119-b953432a2f09_{MAIN}_{Capability.CONTACT_SENSOR}_{Attribute.CONTACT}_{Attribute.CONTACT}", + "refrigerator_door", + "fridge_door", + "binary_sensor.refrigerator_door", + ), + ], +) +async def test_create_issue_with_items( hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" - entity_id = "binary_sensor.volvo_valve" - issue_id = f"deprecated_binary_valve_{entity_id}" + issue_id = f"deprecated_binary_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) assert await async_setup_component( hass, automation.DOMAIN, { automation.DOMAIN: { + "id": "test", "alias": "test", "trigger": {"platform": "state", "entity_id": entity_id}, "action": { @@ -106,13 +138,94 @@ async def test_create_issue( await setup_integration(hass, mock_config_entry) + assert hass.states.get(entity_id).state == STATE_OFF + assert automations_with_entity(hass, entity_id)[0] == "automation.test" assert scripts_with_entity(hass, entity_id)[0] == "script.test" assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_binary_{issue_string}_scripts" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", + } - await hass.config_entries.async_unload(mock_config_entry.entry_id) + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), + [ + ( + "virtual_valve", + f"612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_{MAIN}_{Capability.VALVE}_{Attribute.VALVE}_{Attribute.VALVE}", + "volvo_valve", + "valve", + "binary_sensor.volvo_valve", + ), + ( + "da_ref_normal_000001", + f"7db87911-7dce-1cf2-7119-b953432a2f09_{MAIN}_{Capability.CONTACT_SENSOR}_{Attribute.CONTACT}_{Attribute.CONTACT}", + "refrigerator_door", + "fridge_door", + "binary_sensor.refrigerator_door", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_binary_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state == STATE_OFF + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_binary_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() # Assert the issue is no longer present diff --git a/tests/components/smartthings/test_button.py b/tests/components/smartthings/test_button.py new file mode 100644 index 00000000000..4a348d079ca --- /dev/null +++ b/tests/components/smartthings/test_button.py @@ -0,0 +1,56 @@ +"""Test for the SmartThings button platform.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pysmartthings import Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.smartthings import MAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.BUTTON) + + +@pytest.mark.parametrize("device_fixture", ["da_ks_microwave_0101x"]) +async def test_press( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + freezer.move_to("2023-10-21") + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.microwave_stop"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + Capability.OVEN_OPERATING_STATE, + Command.STOP, + MAIN, + ) diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 380c4072860..75b864598bd 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -817,10 +817,10 @@ async def test_updating_humidity( ( Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES, - ["coolClean", "dryClean"], + ["rush hour", "heat"], ATTR_HVAC_MODES, - [], - [HVACMode.COOL, HVACMode.DRY], + [HVACMode.AUTO], + [HVACMode.AUTO, HVACMode.HEAT], ), ], ids=[ diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 4069c201225..d6e8ef03290 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -513,7 +513,7 @@ async def test_migration( } assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" assert mock_old_config_entry.version == 3 - assert mock_old_config_entry.minor_version == 1 + assert mock_old_config_entry.minor_version == 2 @pytest.mark.usefixtures("current_request_with_host", "use_cloud") @@ -586,7 +586,7 @@ async def test_migration_wrong_location( == "appid123-2be1-4e40-b257-e4ef59083324_397678e5-9995-4a39-9d9f-ae6ba310236c" ) assert mock_old_config_entry.version == 3 - assert mock_old_config_entry.minor_version == 1 + assert mock_old_config_entry.minor_version == 2 @pytest.mark.usefixtures("current_request_with_host") diff --git a/tests/components/smartthings/test_event.py b/tests/components/smartthings/test_event.py new file mode 100644 index 00000000000..34a96e9c6b4 --- /dev/null +++ b/tests/components/smartthings/test_event.py @@ -0,0 +1,99 @@ +"""Test for the SmartThings event platform.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pysmartthings import Attribute, Capability +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.event import ATTR_EVENT_TYPES +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.EVENT) + + +@pytest.mark.parametrize("device_fixture", ["heatit_zpushwall"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + freezer.move_to("2023-10-21") + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + + await trigger_update( + hass, + devices, + "5e5b97f3-3094-44e6-abc0-f61283412d6a", + Capability.BUTTON, + Attribute.BUTTON, + "pushed", + component="button1", + ) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state + == "2023-10-21T00:00:00.000+00:00" + ) + + +@pytest.mark.parametrize("device_fixture", ["heatit_zpushwall"]) +async def test_supported_button_values_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test supported button values update.""" + await setup_integration(hass, mock_config_entry) + + freezer.move_to("2023-10-21") + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + assert hass.states.get("event.livingroom_smart_switch_button1").attributes[ + ATTR_EVENT_TYPES + ] == ["pushed", "held", "down_hold"] + + await trigger_update( + hass, + devices, + "5e5b97f3-3094-44e6-abc0-f61283412d6a", + Capability.BUTTON, + Attribute.SUPPORTED_BUTTON_VALUES, + ["pushed", "held", "down_hold", "pushed_2x"], + component="button1", + ) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + assert hass.states.get("event.livingroom_smart_switch_button1").attributes[ + ATTR_EVENT_TYPES + ] == ["pushed", "held", "down_hold", "pushed_2x"] diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 2083bb7ea24..1d4b124c60d 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -1,24 +1,40 @@ """Tests for the SmartThings component init module.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch +from aiohttp import ClientResponseError, RequestInfo from pysmartthings import ( Attribute, Capability, DeviceResponse, DeviceStatus, + Lifecycle, SmartThingsSinkError, + Subscription, ) -from pysmartthings.models import Subscription import pytest from syrupy import SnapshotAssertion +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.smartthings import EVENT_BUTTON -from homeassistant.components.smartthings.const import CONF_SUBSCRIPTION_ID, DOMAIN +from homeassistant.components.smartthings.const import ( + CONF_INSTALLED_APP_ID, + CONF_LOCATION_ID, + CONF_SUBSCRIPTION_ID, + DOMAIN, + SCOPES, +) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration, trigger_update @@ -264,6 +280,57 @@ async def test_removing_stale_devices( assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_refreshing_expired_token( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing stale devices.""" + with patch( + "homeassistant.components.smartthings.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + request_info=RequestInfo( + url="http://example.com", + method="GET", + headers={}, + real_url="http://example.com", + ), + status=400, + history=(), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert len(hass.config_entries.flow.async_progress()) == 1 + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_error_refreshing_token( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing stale devices.""" + with patch( + "homeassistant.components.smartthings.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + request_info=RequestInfo( + url="http://example.com", + method="GET", + headers={}, + real_url="http://example.com", + ), + status=500, + history=(), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_hub_via_device( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -293,3 +360,340 @@ async def test_hub_via_device( ).via_device_id == hub_device.id ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_deleted_device_runtime( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test devices that are deleted in runtime.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.OFF + + for call in devices.add_device_lifecycle_event_listener.call_args_list: + if call[0][0] == Lifecycle.DELETE: + call[0][1]("96a5ef74-5832-a84b-f1f7-ca799957065d") + await hass.async_block_till_done() + + assert hass.states.get("climate.ac_office_granit") is None + + +@pytest.mark.parametrize( + ( + "device_fixture", + "domain", + "old_unique_id", + "suggested_object_id", + "new_unique_id", + ), + [ + ( + "multipurpose_sensor", + BINARY_SENSOR_DOMAIN, + "7d246592-93db-4d72-a10d-5a51793ece8c.contact", + "deck_door", + "7d246592-93db-4d72-a10d-5a51793ece8c_main_contactSensor_contact_contact", + ), + ( + "multipurpose_sensor", + SENSOR_DOMAIN, + "7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate", + "deck_door_y_coordinate", + "7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_y_coordinate", + ), + ( + "da_ac_rac_000001", + SENSOR_DOMAIN, + "7d246592-93db-4d72-a10d-ca799957065d.energy_meter", + "ac_office_granit_energy", + "7d246592-93db-4d72-a10d-ca799957065d_main_powerConsumptionReport_powerConsumption_energy_meter", + ), + ( + "da_ac_rac_000001", + CLIMATE_DOMAIN, + "7d246592-93db-4d72-a10d-ca799957065d", + "ac_office_granit", + "7d246592-93db-4d72-a10d-ca799957065d_main", + ), + ( + "c2c_shade", + COVER_DOMAIN, + "571af102-15db-4030-b76b-245a691f74a5", + "curtain_1a", + "571af102-15db-4030-b76b-245a691f74a5_main", + ), + ( + "generic_fan_3_speed", + FAN_DOMAIN, + "6d95a8b7-4ee3-429a-a13a-00ec9354170c", + "bedroom_fan", + "6d95a8b7-4ee3-429a-a13a-00ec9354170c_main", + ), + ( + "hue_rgbw_color_bulb", + LIGHT_DOMAIN, + "cb958955-b015-498c-9e62-fc0c51abd054", + "standing_light", + "cb958955-b015-498c-9e62-fc0c51abd054_main", + ), + ( + "yale_push_button_deadbolt_lock", + LOCK_DOMAIN, + "a9f587c5-5d8b-4273-8907-e7f609af5158", + "basement_door_lock", + "a9f587c5-5d8b-4273-8907-e7f609af5158_main", + ), + ( + "smart_plug", + SWITCH_DOMAIN, + "550a1c72-65a0-4d55-b97b-75168e055398", + "arlo_beta_basestation", + "550a1c72-65a0-4d55-b97b-75168e055398_main_switch_switch_switch", + ), + ], +) +async def test_entity_unique_id_migration( + hass: HomeAssistant, + devices: AsyncMock, + expires_at: int, + entity_registry: er.EntityRegistry, + domain: str, + old_unique_id: str, + suggested_object_id: str, + new_unique_id: str, +) -> None: + """Test entity unique ID migration.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(SCOPES), + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123", + }, + version=3, + minor_version=1, + ) + mock_config_entry.add_to_hass(hass) + entry = entity_registry.async_get_or_create( + domain, + DOMAIN, + old_unique_id, + config_entry=mock_config_entry, + suggested_object_id=suggested_object_id, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entry.entity_id) + + assert entry.unique_id == new_unique_id + + +@pytest.mark.parametrize( + ( + "device_fixture", + "domain", + "other_unique_id", + "old_unique_id", + "suggested_object_id", + "new_unique_id", + ), + [ + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState", + "microwave_machine_state", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_machineState_machineState", + ), + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_ovenJobState_ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState", + "microwave_machine_state", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_machineState_machineState", + ), + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime", + "microwave_completion_time", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_completionTime_completionTime", + ), + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_ovenJobState_ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime", + "microwave_completion_time", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_completionTime_completionTime", + ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState", + "dishwasher_machine_state", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState", + ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_dishwasherJobState_dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState", + "dishwasher_machine_state", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState", + ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime", + "dishwasher_completion_time", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_completionTime_completionTime", + ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_dishwasherJobState_dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime", + "dishwasher_completion_time", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_completionTime_completionTime", + ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState", + "dryer_machine_state", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState", + ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_dryerJobState_dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState", + "dryer_machine_state", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState", + ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime", + "dryer_completion_time", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_completionTime_completionTime", + ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_dryerJobState_dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime", + "dryer_completion_time", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_completionTime_completionTime", + ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47.washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.machineState", + "washer_machine_state", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState", + ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_washerJobState_washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.machineState", + "washer_machine_state", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState", + ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47.washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.completionTime", + "washer_completion_time", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_completionTime_completionTime", + ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_washerJobState_washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.completionTime", + "washer_completion_time", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_completionTime_completionTime", + ), + ], +) +async def test_entity_unique_id_migration_machine_state( + hass: HomeAssistant, + devices: AsyncMock, + expires_at: int, + entity_registry: er.EntityRegistry, + domain: str, + other_unique_id: str, + old_unique_id: str, + suggested_object_id: str, + new_unique_id: str, +) -> None: + """Test entity unique ID migration.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(SCOPES), + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123", + }, + version=3, + minor_version=1, + ) + mock_config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + domain, + DOMAIN, + other_unique_id, + config_entry=mock_config_entry, + suggested_object_id="job_state", + ) + entry = entity_registry.async_get_or_create( + domain, + DOMAIN, + old_unique_id, + config_entry=mock_config_entry, + suggested_object_id=suggested_object_id, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entry.entity_id) + + assert entry.unique_id == new_unique_id diff --git a/tests/components/smartthings/test_media_player.py b/tests/components/smartthings/test_media_player.py new file mode 100644 index 00000000000..b7cecfe8408 --- /dev/null +++ b/tests/components/smartthings/test_media_player.py @@ -0,0 +1,432 @@ +"""Test for the SmartThings media player platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command, Status +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + RepeatMode, +) +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_REPEAT_SET, + SERVICE_SHUFFLE_SET, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_OFF, + STATE_PLAYING, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities( + hass, entity_registry, snapshot, Platform.MEDIA_PLAYER + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test media player turn on and off command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + action, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", Capability.SWITCH, command, MAIN + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("muted", "argument"), + [ + (True, "muted"), + (False, "unmuted"), + ], +) +async def test_mute_unmute( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + muted: bool, + argument: str, +) -> None: + """Test media player mute and unmute command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_VOLUME_MUTED: muted}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_MUTE, + Command.SET_MUTE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_set_volume_level( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player set volume level command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_VOLUME_LEVEL: 0.31}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_VOLUME, + Command.SET_VOLUME, + MAIN, + argument=31, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_volume_up( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player increase volume level command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_UP, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_VOLUME, + Command.VOLUME_UP, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_volume_down( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player decrease volume level command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_DOWN, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_VOLUME, + Command.VOLUME_DOWN, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_play( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player play command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.PLAY, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_pause( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player pause command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.PAUSE, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_stop( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player stop command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.STOP, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_previous_track( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player previous track command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK] = { + Attribute.SUPPORTED_PLAYBACK_COMMANDS: Status(["rewind"]) + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.REWIND, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_next_track( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player next track command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK] = { + Attribute.SUPPORTED_PLAYBACK_COMMANDS: Status(["fastForward"]) + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.FAST_FORWARD, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_select_source( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player stop command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_INPUT_SOURCE: "digital"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_INPUT_SOURCE, + Command.SET_INPUT_SOURCE, + MAIN, + "digital", + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("shuffle", "argument"), + [ + (True, "enabled"), + (False, "disabled"), + ], +) +async def test_media_shuffle_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + shuffle: bool, + argument: bool, +) -> None: + """Test media player media shuffle command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK_SHUFFLE] = { + Attribute.PLAYBACK_SHUFFLE: Status(True) + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SHUFFLE_SET, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_SHUFFLE: shuffle}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK_SHUFFLE, + Command.SET_PLAYBACK_SHUFFLE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("repeat", "argument"), + [ + (RepeatMode.OFF, "off"), + (RepeatMode.ONE, "one"), + (RepeatMode.ALL, "all"), + ], +) +async def test_media_repeat_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + repeat: RepeatMode, + argument: bool, +) -> None: + """Test media player repeat mode command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK_REPEAT] = { + Attribute.REPEAT_MODE: Status("one") + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_REPEAT: repeat}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK_REPEAT, + Command.SET_PLAYBACK_REPEAT_MODE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("media_player.soundbar").state == STATE_PLAYING + + await trigger_update( + hass, + devices, + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.SWITCH, + Attribute.SWITCH, + "off", + ) + + assert hass.states.get("media_player.soundbar").state == STATE_OFF diff --git a/tests/components/smartthings/test_number.py b/tests/components/smartthings/test_number.py new file mode 100644 index 00000000000..578b94e050f --- /dev/null +++ b/tests/components/smartthings/test_number.py @@ -0,0 +1,81 @@ +"""Test for the SmartThings number platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.components.smartthings import MAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.NUMBER) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +async def test_set_value( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting a value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.washer_rinse_cycles", ATTR_VALUE: 3}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "f984b91d-f250-9d42-3436-33f09a422a47", + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Command.SET_WASHER_RINSE_CYCLES, + MAIN, + argument="3", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("number.washer_rinse_cycles").state == "2" + + await trigger_update( + hass, + devices, + "f984b91d-f250-9d42-3436-33f09a422a47", + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Attribute.WASHER_RINSE_CYCLES, + "3", + ) + + assert hass.states.get("number.washer_rinse_cycles").state == "3" diff --git a/tests/components/smartthings/test_select.py b/tests/components/smartthings/test_select.py new file mode 100644 index 00000000000..2c5c55239f2 --- /dev/null +++ b/tests/components/smartthings/test_select.py @@ -0,0 +1,121 @@ +"""Test for the SmartThings select platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.smartthings import MAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SELECT) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("select.dryer").state == "stop" + + await trigger_update( + hass, + devices, + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.DRYER_OPERATING_STATE, + Attribute.MACHINE_STATE, + "run", + ) + + assert hass.states.get("select.dryer").state == "run" + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_select_option( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + set_attribute_value( + devices, + Capability.REMOTE_CONTROL_STATUS, + Attribute.REMOTE_CONTROL_ENABLED, + "true", + ) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.dryer", ATTR_OPTION: "run"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.DRYER_OPERATING_STATE, + Command.SET_MACHINE_STATE, + MAIN, + argument="run", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_select_option_without_remote_control( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + set_attribute_value( + devices, + Capability.REMOTE_CONTROL_STATUS, + Attribute.REMOTE_CONTROL_ENABLED, + "false", + ) + await setup_integration(hass, mock_config_entry) + + with pytest.raises( + ServiceValidationError, + match="Can only be updated when remote control is enabled", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.dryer", ATTR_OPTION: "run"}, + blocking=True, + ) + devices.execute_device_command.assert_not_called() diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index c83950de9e9..e90c177bd6d 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -6,9 +6,15 @@ from pysmartthings import Attribute, Capability import pytest from syrupy import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.smartthings.const import DOMAIN, MAIN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from . import setup_integration, snapshot_smartthings_entities, trigger_update @@ -49,3 +55,244 @@ async def test_state_update( ) assert hass.states.get("sensor.ac_office_granit_temperature").state == "20" + + +@pytest.mark.parametrize( + ( + "device_fixture", + "unique_id", + "suggested_object_id", + "issue_string", + "entity_id", + "expected_state", + ), + [ + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_PLAYBACK}_{Attribute.PLAYBACK_STATUS}_{Attribute.PLAYBACK_STATUS}", + "tv_samsung_8_series_49_media_playback_status", + "media_player", + "sensor.tv_samsung_8_series_49_media_playback_status", + STATE_UNKNOWN, + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.AUDIO_VOLUME}_{Attribute.VOLUME}_{Attribute.VOLUME}", + "tv_samsung_8_series_49_volume", + "media_player", + "sensor.tv_samsung_8_series_49_volume", + "13", + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_INPUT_SOURCE}_{Attribute.INPUT_SOURCE}_{Attribute.INPUT_SOURCE}", + "tv_samsung_8_series_49_media_input_source", + "media_player", + "sensor.tv_samsung_8_series_49_media_input_source", + "hdmi1", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_REPEAT}_{Attribute.PLAYBACK_REPEAT_MODE}_{Attribute.PLAYBACK_REPEAT_MODE}", + "galaxy_home_mini_media_playback_repeat", + "media_player", + "sensor.galaxy_home_mini_media_playback_repeat", + "off", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}", + "galaxy_home_mini_media_playback_shuffle", + "media_player", + "sensor.galaxy_home_mini_media_playback_shuffle", + "disabled", + ), + ], +) +async def test_create_issue_with_items( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, + expected_state: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "condition": "state", + "entity_id": entity_id, + "state": "on", + }, + ], + } + } + }, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state == expected_state + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_{issue_string}_scripts" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ( + "device_fixture", + "unique_id", + "suggested_object_id", + "issue_string", + "entity_id", + "expected_state", + ), + [ + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_PLAYBACK}_{Attribute.PLAYBACK_STATUS}_{Attribute.PLAYBACK_STATUS}", + "tv_samsung_8_series_49_media_playback_status", + "media_player", + "sensor.tv_samsung_8_series_49_media_playback_status", + STATE_UNKNOWN, + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.AUDIO_VOLUME}_{Attribute.VOLUME}_{Attribute.VOLUME}", + "tv_samsung_8_series_49_volume", + "media_player", + "sensor.tv_samsung_8_series_49_volume", + "13", + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_INPUT_SOURCE}_{Attribute.INPUT_SOURCE}_{Attribute.INPUT_SOURCE}", + "tv_samsung_8_series_49_media_input_source", + "media_player", + "sensor.tv_samsung_8_series_49_media_input_source", + "hdmi1", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_REPEAT}_{Attribute.PLAYBACK_REPEAT_MODE}_{Attribute.PLAYBACK_REPEAT_MODE}", + "galaxy_home_mini_media_playback_repeat", + "media_player", + "sensor.galaxy_home_mini_media_playback_repeat", + "off", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}", + "galaxy_home_mini_media_playback_shuffle", + "media_player", + "sensor.galaxy_home_mini_media_playback_shuffle", + "disabled", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, + expected_state: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state == expected_state + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index a1e420a8edb..a47ecde7e0d 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -6,7 +6,10 @@ from pysmartthings import Attribute, Capability, Command import pytest from syrupy import SnapshotAssertion -from homeassistant.components.smartthings.const import MAIN +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.smartthings.const import DOMAIN, MAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -17,7 +20,8 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from . import setup_integration, snapshot_smartthings_entities, trigger_update @@ -66,6 +70,39 @@ async def test_switch_turn_on_off( ) +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +@pytest.mark.parametrize( + ("action", "argument"), + [ + (SERVICE_TURN_ON, "on"), + (SERVICE_TURN_OFF, "off"), + ], +) +async def test_command_switch_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + argument: str, +) -> None: + """Test switch turn on and off command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: "switch.dryer_wrinkle_prevent"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.CUSTOM_DRYER_WRINKLE_PREVENT, + Command.SET_DRYER_WRINKLE_PREVENT, + MAIN, + argument, + ) + + @pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) async def test_state_update( hass: HomeAssistant, @@ -87,3 +124,256 @@ async def test_state_update( ) assert hass.states.get("switch.2nd_floor_hallway").state == STATE_OFF + + +@pytest.mark.parametrize( + ("device_fixture", "device_id", "suggested_object_id", "issue_string"), + [ + ( + "da_ks_cooktop_31001", + "808dbd84-f357-47e2-a0cd-3b66fa22d584", + "induction_hob", + "appliance", + ), + ( + "da_ks_microwave_0101x", + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "microwave", + "appliance", + ), + ( + "da_wm_dw_000001", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "dishwasher", + "appliance", + ), + ( + "da_wm_sc_000001", + "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "airdresser", + "appliance", + ), + ( + "da_wm_wd_000001", + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "dryer", + "appliance", + ), + ( + "da_wm_wm_000001", + "f984b91d-f250-9d42-3436-33f09a422a47", + "washer", + "appliance", + ), + ( + "hw_q80r_soundbar", + "afcf3b91-0000-1111-2222-ddff2a0a6577", + "soundbar", + "media_player", + ), + ( + "vd_network_audio_002s", + "0d94e5db-8501-2355-eb4f-214163702cac", + "soundbar_living", + "media_player", + ), + ( + "vd_stv_2017_k", + "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "tv_samsung_8_series_49", + "media_player", + ), + ], +) +async def test_create_issue_with_items( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + device_id: str, + suggested_object_id: str, + issue_string: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + entity_id = f"switch.{suggested_object_id}" + issue_id = f"deprecated_switch_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + f"{device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "condition": "state", + "entity_id": entity_id, + "state": "on", + }, + ], + } + } + }, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state in [STATE_OFF, STATE_ON] + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_switch_{issue_string}_scripts" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ("device_fixture", "device_id", "suggested_object_id", "issue_string"), + [ + ( + "da_ks_cooktop_31001", + "808dbd84-f357-47e2-a0cd-3b66fa22d584", + "induction_hob", + "appliance", + ), + ( + "da_ks_microwave_0101x", + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "microwave", + "appliance", + ), + ( + "da_wm_dw_000001", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "dishwasher", + "appliance", + ), + ( + "da_wm_sc_000001", + "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "airdresser", + "appliance", + ), + ( + "da_wm_wd_000001", + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "dryer", + "appliance", + ), + ( + "da_wm_wm_000001", + "f984b91d-f250-9d42-3436-33f09a422a47", + "washer", + "appliance", + ), + ( + "hw_q80r_soundbar", + "afcf3b91-0000-1111-2222-ddff2a0a6577", + "soundbar", + "media_player", + ), + ( + "vd_network_audio_002s", + "0d94e5db-8501-2355-eb4f-214163702cac", + "soundbar_living", + "media_player", + ), + ( + "vd_stv_2017_k", + "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "tv_samsung_8_series_49", + "media_player", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + device_id: str, + suggested_object_id: str, + issue_string: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + entity_id = f"switch.{suggested_object_id}" + issue_id = f"deprecated_switch_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + f"{device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state in [STATE_OFF, STATE_ON] + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_switch_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/smartthings/test_update.py b/tests/components/smartthings/test_update.py new file mode 100644 index 00000000000..8c3d9e1a968 --- /dev/null +++ b/tests/components/smartthings/test_update.py @@ -0,0 +1,142 @@ +"""Test for the SmartThings update platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.smartthings.const import MAIN +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.UPDATE) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_installing_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test installing an update.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.front_door_open_closed_sensor_firmware"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Command.UPDATE_FIRMWARE, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").state + == STATE_ON + ) + + await trigger_update( + hass, + devices, + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Attribute.CURRENT_VERSION, + "00000104", + ) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").state + == STATE_OFF + ) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_state_progress_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state progress update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").attributes[ + ATTR_IN_PROGRESS + ] + is False + ) + + await trigger_update( + hass, + devices, + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Attribute.STATE, + "updateInProgress", + ) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").attributes[ + ATTR_IN_PROGRESS + ] + is True + ) + + +@pytest.mark.parametrize("device_fixture", ["centralite"]) +async def test_state_update_available( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update available.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_OFF + + await trigger_update( + hass, + devices, + "d0268a69-abfb-4c92-a646-61cec2e510ad", + Capability.FIRMWARE_UPDATE, + Attribute.AVAILABLE_VERSION, + "16015011", + ) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_ON diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index c8933029ce6..4ecfe9366e3 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -193,7 +193,7 @@ async def test_zeroconf_flow_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 3 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_zeroconf_unsupported_abort( @@ -406,7 +406,7 @@ async def test_user_invalid_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 3 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_user_cannot_connect( @@ -443,7 +443,7 @@ async def test_user_cannot_connect( assert result2["title"] == "SLZB-06p7" assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 3 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_auth_cannot_connect( diff --git a/tests/components/smlight/test_sensor.py b/tests/components/smlight/test_sensor.py index f130d7ccf30..bec73bc514a 100644 --- a/tests/components/smlight/test_sensor.py +++ b/tests/components/smlight/test_sensor.py @@ -2,17 +2,18 @@ from unittest.mock import MagicMock -from pysmlight import Sensors +from pysmlight import Info, Sensors import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.smlight.const import DOMAIN from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform pytestmark = [ pytest.mark.usefixtures( @@ -73,3 +74,38 @@ async def test_zigbee_uptime_disconnected( state = hass.states.get("sensor.mock_title_zigbee_uptime") assert state.state == STATE_UNKNOWN + + +async def test_zigbee2_temp_sensor( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test for zb_temp2 if device has second radio.""" + mock_smlight_client.get_sensors.return_value = Sensors(zb_temp2=20.45) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.mock_title_zigbee_chip_temp_2") + assert state + assert state.state == "20.45" + + +async def test_zigbee_type_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test for zigbee type sensor with second radio.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info.from_dict( + load_json_object_fixture("info-MR1.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.mock_title_zigbee_type") + assert state + assert state.state == "coordinator" + + state = hass.states.get("sensor.mock_title_zigbee_type_2") + assert state + assert state.state == "router" diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 86d19968910..d120a08d519 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -154,10 +154,9 @@ async def test_update_zigbee2_firmware( mock_smlight_client: MagicMock, ) -> None: """Test update of zigbee2 firmware where available.""" + mock_info = Info.from_dict(load_json_object_fixture("info-MR1.json", DOMAIN)) mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = Info.from_dict( - load_json_object_fixture("info-MR1.json", DOMAIN) - ) + mock_smlight_client.get_info.return_value = mock_info await setup_integration(hass, mock_config_entry) entity_id = "update.mock_title_zigbee_firmware_2" state = hass.states.get(entity_id) @@ -177,17 +176,17 @@ async def test_update_zigbee2_firmware( event_function = get_mock_event_function(mock_smlight_client, SmEvents.FW_UPD_done) event_function(MOCK_FIRMWARE_DONE) - with patch( - "homeassistant.components.smlight.update.get_radio", return_value=MOCK_RADIO - ): - freezer.tick(timedelta(seconds=5)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "20240716" - assert state.attributes[ATTR_LATEST_VERSION] == "20240716" + mock_info.radios[1] = MOCK_RADIO + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "20240716" + assert state.attributes[ATTR_LATEST_VERSION] == "20240716" async def test_update_legacy_firmware_v2( diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index 8c0e897947a..154ddb9253e 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -94,10 +94,12 @@ SENSOR_OUTPUT = { @pytest.fixture -def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: +async def mock_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Start the Home Assistant HTTP component.""" with patch("homeassistant.components.spaceapi", return_value=True): - hass.loop.run_until_complete(async_setup_component(hass, "spaceapi", CONFIG)) + await async_setup_component(hass, "spaceapi", CONFIG) hass.states.async_set( "test.temp1", @@ -126,7 +128,7 @@ def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> Tes "test.hum1", 88, attributes={ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE} ) - return hass.loop.run_until_complete(hass_client()) + return await hass_client() async def test_spaceapi_get(hass: HomeAssistant, mock_client) -> None: diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 40582dc4be3..dd7f2a7bbc3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -1,6 +1,7 @@ """Fixtures for Stookwijzer integration tests.""" from collections.abc import Generator +from typing import Required, TypedDict from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -12,6 +13,14 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +class Forecast(TypedDict): + """Typed Stookwijzer forecast dict.""" + + datetime: Required[str] + advice: str | None + final: bool | None + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -80,6 +89,28 @@ def mock_stookwijzer() -> Generator[MagicMock]: client.windspeed_ms = 2.5 client.windspeed_bft = 2 client.advice = "code_yellow" + client.async_get_forecast.return_value = ( + Forecast( + datetime="2025-02-12T17:00:00+01:00", + advice="code_yellow", + final=True, + ), + Forecast( + datetime="2025-02-12T23:00:00+01:00", + advice="code_yellow", + final=True, + ), + Forecast( + datetime="2025-02-13T05:00:00+01:00", + advice="code_orange", + final=False, + ), + Forecast( + datetime="2025-02-13T11:00:00+01:00", + advice="code_orange", + final=False, + ), + ) yield stookwijzer_mock diff --git a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr index e2535d54466..452b5bd0a30 100644 --- a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr +++ b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr @@ -3,6 +3,28 @@ dict({ 'advice': 'code_yellow', 'air_quality_index': 2, + 'forecast': list([ + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T17:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T23:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T05:00:00+01:00', + 'final': False, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T11:00:00+01:00', + 'final': False, + }), + ]), 'windspeed_ms': 2.5, }) # --- diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index a7aeae25ac7..ec848c61338 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -27,12 +27,10 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) - ) + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) async def test_sunset_trigger( diff --git a/tests/components/sunweg/__init__.py b/tests/components/sunweg/__init__.py index 1453483a3fd..d9dac10eeb6 100644 --- a/tests/components/sunweg/__init__.py +++ b/tests/components/sunweg/__init__.py @@ -1 +1 @@ -"""Tests for the sunweg component.""" +"""Tests for the Sun WEG integration.""" diff --git a/tests/components/sunweg/common.py b/tests/components/sunweg/common.py deleted file mode 100644 index 096113f6609..00000000000 --- a/tests/components/sunweg/common.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Common functions needed to setup tests for Sun WEG.""" - -from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME - -from tests.common import MockConfigEntry - -SUNWEG_USER_INPUT = { - CONF_USERNAME: "username", - CONF_PASSWORD: "password", -} - -SUNWEG_MOCK_ENTRY = MockConfigEntry( - domain=DOMAIN, - unique_id=0, - data={ - CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", - CONF_PLANT_ID: 0, - CONF_NAME: "Name", - }, -) diff --git a/tests/components/sunweg/conftest.py b/tests/components/sunweg/conftest.py deleted file mode 100644 index db94b9cc5c8..00000000000 --- a/tests/components/sunweg/conftest.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Conftest for SunWEG tests.""" - -from datetime import datetime - -import pytest -from sunweg.device import MPPT, Inverter, Phase, String -from sunweg.plant import Plant - - -@pytest.fixture -def string_fixture() -> String: - """Define String fixture.""" - return String("STR1", 450.3, 23.4, 0) - - -@pytest.fixture -def mppt_fixture(string_fixture) -> MPPT: - """Define MPPT fixture.""" - mppt = MPPT("mppt") - mppt.strings.append(string_fixture) - return mppt - - -@pytest.fixture -def phase_fixture() -> Phase: - """Define Phase fixture.""" - return Phase("PhaseA", 120.0, 3.2, 0, 0) - - -@pytest.fixture -def inverter_fixture(phase_fixture, mppt_fixture) -> Inverter: - """Define inverter fixture.""" - inverter = Inverter( - 21255, - "INVERSOR01", - "J63T233018RE074", - 23.2, - 0.0, - 0.0, - "MWh", - 0, - "kWh", - 0.0, - 1, - 0, - "kW", - ) - inverter.phases.append(phase_fixture) - inverter.mppts.append(mppt_fixture) - return inverter - - -@pytest.fixture -def plant_fixture(inverter_fixture) -> Plant: - """Define Plant fixture.""" - plant = Plant( - 123456, - "Plant #123", - 29.5, - 0.5, - 0, - 12.786912, - 24.0, - "kWh", - 332.2, - 0.012296, - datetime(2023, 2, 16, 14, 22, 37), - ) - plant.inverters.append(inverter_fixture) - return plant - - -@pytest.fixture -def plant_fixture_alternative(inverter_fixture) -> Plant: - """Define Plant fixture.""" - plant = Plant( - 123456, - "Plant #123", - 29.5, - 0.5, - 0, - 12.786912, - 24.0, - "kWh", - 332.2, - 0.012296, - None, - ) - plant.inverters.append(inverter_fixture) - return plant diff --git a/tests/components/sunweg/test_config_flow.py b/tests/components/sunweg/test_config_flow.py deleted file mode 100644 index 8103003d7fb..00000000000 --- a/tests/components/sunweg/test_config_flow.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Tests for the Sun WEG server config flow.""" - -from unittest.mock import patch - -from sunweg.api import APIHelper, SunWegApiError - -from homeassistant import config_entries -from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from .common import SUNWEG_MOCK_ENTRY, SUNWEG_USER_INPUT - -from tests.common import MockConfigEntry - - -async def test_show_authenticate_form(hass: HomeAssistant) -> None: - """Test that the setup form is served.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - -async def test_incorrect_login(hass: HomeAssistant) -> None: - """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch.object(APIHelper, "authenticate", return_value=False): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_server_unavailable(hass: HomeAssistant) -> None: - """Test when the SunWEG server don't respond.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch.object( - APIHelper, "authenticate", side_effect=SunWegApiError("Internal Server Error") - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "timeout_connect"} - - -async def test_reauth(hass: HomeAssistant, plant_fixture, inverter_fixture) -> None: - """Test reauth flow.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - - entries = hass.config_entries.async_entries() - assert len(entries) == 1 - assert entries[0].data[CONF_USERNAME] == SUNWEG_MOCK_ENTRY.data[CONF_USERNAME] - assert entries[0].data[CONF_PASSWORD] == SUNWEG_MOCK_ENTRY.data[CONF_PASSWORD] - - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - with patch.object(APIHelper, "authenticate", return_value=False): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=SUNWEG_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_auth"} - - with patch.object( - APIHelper, "authenticate", side_effect=SunWegApiError("Internal Server Error") - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=SUNWEG_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "timeout_connect"} - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object(APIHelper, "listPlants", return_value=[plant_fixture]), - patch.object(APIHelper, "plant", return_value=plant_fixture), - patch.object(APIHelper, "inverter", return_value=inverter_fixture), - patch.object(APIHelper, "complete_inverter"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=SUNWEG_USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - - entries = hass.config_entries.async_entries() - - assert len(entries) == 1 - assert entries[0].data[CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] - assert entries[0].data[CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] - - -async def test_no_plants_on_account(hass: HomeAssistant) -> None: - """Test registering an integration with wrong auth then with no plants available.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch.object(APIHelper, "authenticate", return_value=False): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object(APIHelper, "listPlants", return_value=[]), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_plants" - - -async def test_multiple_plant_ids(hass: HomeAssistant, plant_fixture) -> None: - """Test registering an integration and finishing flow with an selected plant_id.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object( - APIHelper, "listPlants", return_value=[plant_fixture, plant_fixture] - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "plant" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PLANT_ID: 123456} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] - assert result["data"][CONF_PLANT_ID] == 123456 - - -async def test_one_plant_on_account(hass: HomeAssistant, plant_fixture) -> None: - """Test registering an integration and finishing flow with current plant_id.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object( - APIHelper, - "listPlants", - return_value=[plant_fixture], - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] - assert result["data"][CONF_PLANT_ID] == 123456 - - -async def test_existing_plant_configured(hass: HomeAssistant, plant_fixture) -> None: - """Test entering an existing plant_id.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=123456) - entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object( - APIHelper, - "listPlants", - return_value=[plant_fixture], - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py index 6cbe38a128b..964b48aebcb 100644 --- a/tests/components/sunweg/test_init.py +++ b/tests/components/sunweg/test_init.py @@ -1,209 +1,79 @@ -"""Tests for the Sun WEG init.""" +"""Tests for the Sun WEG integration.""" -import json -from unittest.mock import MagicMock, patch - -from sunweg.api import APIHelper, SunWegApiError - -from homeassistant.components.sunweg import SunWEGData -from homeassistant.components.sunweg.const import DOMAIN, DeviceType -from homeassistant.components.sunweg.sensor.sensor_entity_description import ( - SunWEGSensorEntityDescription, +from homeassistant.components.sunweg import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, ) -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers import issue_registry as ir -from .common import SUNWEG_MOCK_ENTRY +from tests.common import MockConfigEntry -async def test_methods(hass: HomeAssistant, plant_fixture, inverter_fixture) -> None: - """Test methods.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object(APIHelper, "listPlants", return_value=[plant_fixture]), - patch.object(APIHelper, "plant", return_value=plant_fixture), - patch.object(APIHelper, "inverter", return_value=inverter_fixture), - patch.object(APIHelper, "complete_inverter"), - ): - assert await async_setup_component(hass, DOMAIN, mock_entry.data) - await hass.async_block_till_done() - assert await hass.config_entries.async_unload(mock_entry.entry_id) - - -async def test_setup_wrongpass(hass: HomeAssistant) -> None: - """Test setup with wrong pass.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - with patch.object(APIHelper, "authenticate", return_value=False): - assert await async_setup_component(hass, DOMAIN, mock_entry.data) - await hass.async_block_till_done() - - -async def test_setup_error_500(hass: HomeAssistant) -> None: - """Test setup with wrong pass.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - with patch.object( - APIHelper, "authenticate", side_effect=SunWegApiError("Error 500") - ): - assert await async_setup_component(hass, DOMAIN, mock_entry.data) - await hass.async_block_till_done() - - -async def test_sunwegdata_update_exception() -> None: - """Test SunWEGData exception on update.""" - api = MagicMock() - api.plant = MagicMock(side_effect=json.decoder.JSONDecodeError("Message", "Doc", 1)) - data = SunWEGData(api, 0) - data.update() - assert data.data is None - - -async def test_sunwegdata_update_success(plant_fixture) -> None: - """Test SunWEGData success on update.""" - api = MagicMock() - api.plant = MagicMock(return_value=plant_fixture) - api.complete_inverter = MagicMock() - data = SunWEGData(api, 0) - data.update() - assert data.data.id == plant_fixture.id - assert data.data.name == plant_fixture.name - assert data.data.kwh_per_kwp == plant_fixture.kwh_per_kwp - assert data.data.last_update == plant_fixture.last_update - assert data.data.performance_rate == plant_fixture.performance_rate - assert data.data.saving == plant_fixture.saving - assert len(data.data.inverters) == 1 - - -async def test_sunwegdata_update_success_alternative(plant_fixture_alternative) -> None: - """Test SunWEGData success on update.""" - api = MagicMock() - api.plant = MagicMock(return_value=plant_fixture_alternative) - api.complete_inverter = MagicMock() - data = SunWEGData(api, 0) - data.update() - assert data.data.id == plant_fixture_alternative.id - assert data.data.name == plant_fixture_alternative.name - assert data.data.kwh_per_kwp == plant_fixture_alternative.kwh_per_kwp - assert data.data.last_update == plant_fixture_alternative.last_update - assert data.data.performance_rate == plant_fixture_alternative.performance_rate - assert data.data.saving == plant_fixture_alternative.saving - assert len(data.data.inverters) == 1 - - -async def test_sunwegdata_get_api_value_none(plant_fixture) -> None: - """Test SunWEGData none return on get_api_value.""" - api = MagicMock() - data = SunWEGData(api, 123456) - data.data = plant_fixture - assert data.get_api_value("variable", DeviceType.INVERTER, 0, "deep_name") is None - assert data.get_api_value("variable", DeviceType.STRING, 21255, "deep_name") is None - - -async def test_sunwegdata_get_data_drop_threshold() -> None: - """Test SunWEGData get_data with drop threshold.""" - api = MagicMock() - data = SunWEGData(api, 123456) - data.get_api_value = MagicMock() - entity_description = SunWEGSensorEntityDescription( - api_variable_key="variable", key="key", previous_value_drop_threshold=0.1 +async def test_sunweg_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Sun WEG configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, ) - data.get_api_value.return_value = 3.0 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 2.91 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 2.8 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (2.8, None) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED - -async def test_sunwegdata_get_data_never_reset() -> None: - """Test SunWEGData get_data with never reset.""" - api = MagicMock() - data = SunWEGData(api, 123456) - data.get_api_value = MagicMock() - entity_description = SunWEGSensorEntityDescription( - api_variable_key="variable", key="key", never_resets=True + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, ) - data.get_api_value.return_value = 3.0 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 0 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 2.8 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (2.8, None) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) -async def test_reauth_started(hass: HomeAssistant) -> None: - """Test reauth flow started.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - with patch.object(APIHelper, "authenticate", return_value=False): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.SETUP_ERROR - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "reauth_confirm" + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 4d6794b962f..f57c8c107b2 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -294,3 +294,145 @@ REMOTE_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=False, tx_power=-127, ) + + +WOHUB2_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoHub2", + manufacturer_data={ + 2409: b"\xe7\x06\x1dx\x99y\x00\xffg\xe2\xbf]\x84\x04\x9a,\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoHub2", + manufacturer_data={ + 2409: b"\xe7\x06\x1dx\x99y\x00\xffg\xe2\xbf]\x84\x04\x9a,\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoHub2"), + time=0, + connectable=True, + tx_power=-127, +) + + +WOCURTAIN3_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoCurtain3", + address="AA:BB:CC:DD:EE:FF", + manufacturer_data={2409: b"\xcf;Zwu\x0c\x19\x0b\x00\x11D\x006"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"{\xc06\x00\x11D"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoCurtain3", + manufacturer_data={2409: b"\xcf;Zwu\x0c\x19\x0b\x00\x11D\x006"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"{\xc06\x00\x11D"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoCurtain3"), + time=0, + connectable=True, + tx_power=-127, +) + + +WOBLINDTILT_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoBlindTilt", + address="AA:BB:CC:DD:EE:FF", + manufacturer_data={2409: b"\xfbgA`\x98\xe8\x1d%2\x11\x84"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"x\x00*"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoBlindTilt", + manufacturer_data={2409: b"\xfbgA`\x98\xe8\x1d%2\x11\x84"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"x\x00*"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoBlindTilt"), + time=0, + connectable=True, + tx_power=-127, +) + + +def make_advertisement( + address: str, manufacturer_data: bytes, service_data: bytes +) -> BluetoothServiceInfoBleak: + """Make a dummy advertisement.""" + return BluetoothServiceInfoBleak( + name="Test Device", + address=address, + manufacturer_data={2409: manufacturer_data}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": service_data}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Test Device", + manufacturer_data={2409: manufacturer_data}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": service_data}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device(address, "Test Device"), + time=0, + connectable=True, + tx_power=-127, + ) + + +HUBMINI_MATTER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"%\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "HubMini Matter"), + time=0, + connectable=True, + tx_power=-127, +) + + +ROLLER_SHADE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "RollerShade"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py index 44f68a1c8ae..aff94626a68 100644 --- a/tests/components/switchbot/conftest.py +++ b/tests/components/switchbot/conftest.py @@ -2,7 +2,26 @@ import pytest +from homeassistant.components.switchbot.const import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_SENSOR_TYPE + +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" + + +@pytest.fixture +def mock_entry_factory(): + """Fixture to create a MockConfigEntry with a customizable sensor type.""" + return lambda sensor_type="curtain": MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: sensor_type, + }, + unique_id="aabbccddeeff", + ) diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py new file mode 100644 index 00000000000..b52436f1932 --- /dev/null +++ b/tests/components/switchbot/test_cover.py @@ -0,0 +1,492 @@ +"""Test the switchbot covers.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, +) +from homeassistant.core import HomeAssistant, State + +from . import ( + ROLLER_SHADE_SERVICE_INFO, + WOBLINDTILT_SERVICE_INFO, + WOCURTAIN3_SERVICE_INFO, + make_advertisement, +) + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_curtain3_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the Curtain3.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="curtain") + + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 50}, + ) + ], + ) + + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + +async def test_curtain3_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test Curtain3 controlling.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="curtain") + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b"{\xc06\x00\x11D" + + # Test open + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b\x05\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 95 + + # Test close + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b\x58\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 12 + + # Test stop + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b\x3c\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 40 + + # Test set position + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b(\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + +async def test_blindtilt_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the blindtilt.""" + inject_bluetooth_service_info(hass, WOBLINDTILT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="blind_tilt") + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 40}, + ) + ], + ) + + entry.add_to_hass(hass) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.update", + new=AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 40 + + +async def test_blindtilt_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test blindtilt controlling.""" + inject_bluetooth_service_info(hass, WOBLINDTILT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="blind_tilt") + entry.add_to_hass(hass) + info = { + "motionDirection": { + "opening": False, + "closing": False, + "up": False, + "down": False, + }, + } + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + new=AsyncMock(return_value=info), + ), + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b"x\x00*" + + # Test open + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%F\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 70 + + # Test close + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%\x0f\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 15 + + # Test stop + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%\n\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 + + # Test set position + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%2\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_TILT_POSITION: 50}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + +async def test_roller_shade_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the RollerShade.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 60}, + ) + ], + ) + + entry.add_to_hass(hass) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.update", + new=AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + +async def test_roller_shade_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test Roller Shade controlling.""" + inject_bluetooth_service_info(hass, ROLLER_SHADE_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + entry.add_to_hass(hass) + info = {"battery": 39} + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ), + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b",\x00'\x9f\x11\x04" + + # Test open + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\xa0\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 68 + + # Test close + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5a\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 10 + + # Test stop + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5f\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 5 + + # Test set position + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x32\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 6a7111a054e..72ec3a8c727 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -22,9 +22,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( + HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, + WOHUB2_SERVICE_INFO, WOMETERTHPC_SERVICE_INFO, WORELAY_SWITCH_1PM_SERVICE_INFO, ) @@ -234,3 +236,107 @@ async def test_remote(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hub2_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for WoHub2.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, WOHUB2_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "hub2", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 5 + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "26.4" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "44" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + light_level_sensor = hass.states.get("sensor.test_name_light_level") + light_level_sensor_attrs = light_level_sensor.attributes + assert light_level_sensor.state == "4" + assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Light level" + assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "Level" + + light_level_sensor = hass.states.get("sensor.test_name_illuminance") + light_level_sensor_attrs = light_level_sensor.attributes + assert light_level_sensor.state == "30" + assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Illuminance" + assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "lx" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hubmini_matter_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for HubMini Matter.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, HUBMINI_MATTER_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "hubmini_matter", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 3 + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "24.1" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "53" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 8475a253231..db0062b45bf 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -4,9 +4,13 @@ from io import StringIO from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from synology_dsm.api.file_station.models import SynoFileFile, SynoFileSharedFolder -from synology_dsm.exceptions import SynologyDSMAPIErrorException +from synology_dsm.exceptions import ( + SynologyDSMAPIErrorException, + SynologyDSMRequestException, +) from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -279,6 +283,50 @@ async def test_agents_on_unload( } +async def test_agents_on_changed_update_success( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test backup agent on changed update success of coordintaor.""" + client = await hass_ws_client(hass) + + # config entry is loaded + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 2 + + # coordinator update was successful + freezer.tick(910) # 15 min interval + 10s + await hass.async_block_till_done(wait_background_tasks=True) + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 2 + + # coordinator update was un-successful + setup_dsm_with_filestation.update.side_effect = SynologyDSMRequestException( + OSError() + ) + freezer.tick(910) + await hass.async_block_till_done(wait_background_tasks=True) + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 1 + + # coordinator update was successful again + setup_dsm_with_filestation.update.side_effect = None + freezer.tick(910) + await hass.async_block_till_done(wait_background_tasks=True) + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 2 + + async def test_agents_list_backups( hass: HomeAssistant, setup_dsm_with_filestation: MagicMock, diff --git a/tests/components/system_bridge/test_init.py b/tests/components/system_bridge/test_init.py index 7632a0c8157..25ccbdeb46c 100644 --- a/tests/components/system_bridge/test_init.py +++ b/tests/components/system_bridge/test_init.py @@ -81,3 +81,53 @@ async def test_migration_minor_future_version(hass: HomeAssistant) -> None: assert config_entry.minor_version == config_entry_minor_version assert config_entry.data == config_entry_data assert config_entry.state is ConfigEntryState.LOADED + + +async def test_setup_timeout(hass: HomeAssistant) -> None: + """Test setup with timeout error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FIXTURE_UUID, + data=FIXTURE_USER_INPUT, + version=SystemBridgeConfigFlow.VERSION, + minor_version=SystemBridgeConfigFlow.MINOR_VERSION, + ) + + with patch( + "systembridgeconnector.version.Version.check_supported", + side_effect=TimeoutError, + ): + config_entry.add_to_hass(hass) + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_get_data_timeout(hass: HomeAssistant) -> None: + """Test coordinator handling timeout during get_data.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FIXTURE_UUID, + data=FIXTURE_USER_INPUT, + version=SystemBridgeConfigFlow.VERSION, + minor_version=SystemBridgeConfigFlow.MINOR_VERSION, + ) + + with ( + patch( + "systembridgeconnector.version.Version.check_supported", + return_value=True, + ), + patch( + "homeassistant.components.system_bridge.coordinator.SystemBridgeDataUpdateCoordinator.async_get_data", + side_effect=TimeoutError, + ), + ): + config_entry.add_to_hass(hass) + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr index 1ee9067a528..8108e4777c8 100644 --- a/tests/components/systemmonitor/snapshots/test_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -114,34 +114,34 @@ # name: test_sensor[System Monitor Last boot - state] '2024-02-24T15:00:00+00:00' # --- -# name: test_sensor[System Monitor Load (15m) - attributes] +# name: test_sensor[System Monitor Load (15 min) - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (15m)', + 'friendly_name': 'System Monitor Load (15 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (15m) - state] +# name: test_sensor[System Monitor Load (15 min) - state] '3' # --- -# name: test_sensor[System Monitor Load (1m) - attributes] +# name: test_sensor[System Monitor Load (1 min) - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (1m)', + 'friendly_name': 'System Monitor Load (1 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (1m) - state] +# name: test_sensor[System Monitor Load (1 min) - state] '1' # --- -# name: test_sensor[System Monitor Load (5m) - attributes] +# name: test_sensor[System Monitor Load (5 min) - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (5m)', + 'friendly_name': 'System Monitor Load (5 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (5m) - state] +# name: test_sensor[System Monitor Load (5 min) - state] '2' # --- # name: test_sensor[System Monitor Memory free - attributes] diff --git a/tests/components/tado/__init__.py b/tests/components/tado/__init__.py index 11d199f01a1..e6b6257e6ea 100644 --- a/tests/components/tado/__init__.py +++ b/tests/components/tado/__init__.py @@ -1 +1 @@ -"""Tests for the tado integration.""" +"""Tests for the Tado integration.""" diff --git a/tests/components/tado/conftest.py b/tests/components/tado/conftest.py new file mode 100644 index 00000000000..1aa62b218a2 --- /dev/null +++ b/tests/components/tado/conftest.py @@ -0,0 +1,50 @@ +"""Fixtures for Tado tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from PyTado.http import DeviceActivationStatus +import pytest + +from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_tado_api() -> Generator[MagicMock]: + """Mock the Tado API.""" + with ( + patch("homeassistant.components.tado.Tado") as mock_tado, + patch("homeassistant.components.tado.config_flow.Tado", new=mock_tado), + ): + client = mock_tado.return_value + client.device_verification_url.return_value = ( + "https://login.tado.com/oauth2/device?user_code=TEST" + ) + client.device_activation_status.return_value = DeviceActivationStatus.COMPLETED + client.get_me.return_value = load_json_object_fixture("me.json", DOMAIN) + client.get_refresh_token.return_value = "refresh" + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock the setup entry.""" + with patch( + "homeassistant.components.tado.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_REFRESH_TOKEN: "refresh", + }, + unique_id="1", + version=2, + ) diff --git a/tests/components/tado/fixtures/device_authorize.json b/tests/components/tado/fixtures/device_authorize.json new file mode 100644 index 00000000000..aacd171fafd --- /dev/null +++ b/tests/components/tado/fixtures/device_authorize.json @@ -0,0 +1,8 @@ +{ + "device_code": "ABCD", + "expires_in": 300, + "interval": 5, + "user_code": "TEST", + "verification_uri": "https://login.tado.com/oauth2/device", + "verification_uri_complete": "https://login.tado.com/oauth2/device?user_code=TEST" +} diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..eefb818a88c --- /dev/null +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -0,0 +1,143 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'data': dict({ + 'device': dict({ + 'WR1': dict({ + 'accessPointWiFi': dict({ + 'ssid': 'tado8480', + }), + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'commandTableUploadState': 'FINISHED', + 'connectionState': dict({ + 'timestamp': '2020-03-23T18:30:07.377Z', + 'value': True, + }), + 'currentFwVersion': '59.4', + 'deviceType': 'WR02', + 'serialNo': 'WR1', + 'shortSerialNo': 'WR1', + 'temperatureOffset': dict({ + 'celsius': -1.0, + 'fahrenheit': -1.8, + }), + }), + 'WR4': dict({ + 'accessPointWiFi': dict({ + 'ssid': 'tado8480', + }), + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'commandTableUploadState': 'FINISHED', + 'connectionState': dict({ + 'timestamp': '2020-03-23T18:30:07.377Z', + 'value': True, + }), + 'currentFwVersion': '59.4', + 'deviceType': 'WR02', + 'duties': list([ + 'ZONE_UI', + 'ZONE_DRIVER', + 'ZONE_LEADER', + ]), + 'serialNo': 'WR4', + 'shortSerialNo': 'WR4', + 'temperatureOffset': dict({ + 'celsius': -1.0, + 'fahrenheit': -1.8, + }), + }), + }), + 'geofence': dict({ + 'presence': 'HOME', + 'presenceLocked': False, + }), + 'weather': dict({ + 'outsideTemperature': dict({ + 'celsius': 7.46, + 'fahrenheit': 45.43, + 'precision': dict({ + 'celsius': 0.01, + 'fahrenheit': 0.01, + }), + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'TEMPERATURE', + }), + 'solarIntensity': dict({ + 'percentage': 2.1, + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'PERCENTAGE', + }), + 'weatherState': dict({ + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'WEATHER_STATE', + 'value': 'FOGGY', + }), + }), + 'zone': dict({ + '1': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=1, current_temp=20.65, connection=None, current_temp_timestamp='2020-03-10T07:44:11.947Z', current_humidity=45.2, current_humidity_timestamp='2020-03-10T07:44:11.947Z', is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=20.5, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp='2020-03-10T07:47:45.978Z', ac_power=None, heating_power=None, heating_power_percentage=0.0, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '2': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=2, current_temp=None, connection=None, current_temp_timestamp=None, current_humidity=None, current_humidity_timestamp=None, is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='SMART_SCHEDULE', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=65.0, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp=None, ac_power=None, heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type=None, overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '3': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=3, current_temp=24.76, connection=None, current_temp_timestamp='2020-03-05T03:57:38.850Z', current_humidity=60.9, current_humidity_timestamp='2020-03-05T03:57:38.850Z', is_away=False, current_hvac_action='COOLING', current_fan_speed='AUTO', current_fan_level=None, current_hvac_mode='COOL', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=17.78, available=True, power='ON', link='ONLINE', ac_power_timestamp='2020-03-05T04:01:07.162Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='TADO_MODE', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '4': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=4, current_temp=None, connection=None, current_temp_timestamp=None, current_humidity=None, current_humidity_timestamp=None, is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='HEATING', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=30.0, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp=None, ac_power=None, heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='TADO_MODE', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '5': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=5, current_temp=20.88, connection=None, current_temp_timestamp='2020-03-28T02:09:27.830Z', current_humidity=42.3, current_humidity_timestamp='2020-03-28T02:09:27.830Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level=None, current_hvac_mode='SMART_SCHEDULE', current_swing_mode='ON', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=20.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2020-03-27T23:02:22.260Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type=None, overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '6': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=6, current_temp=24.3, connection=None, current_temp_timestamp='2024-06-28T22: 23: 15.679Z', current_humidity=70.9, current_humidity_timestamp='2024-06-28T22: 23: 15.679Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level='LEVEL3', current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='ON', current_horizontal_swing_mode='ON', target_temp=25.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2022-07-13T18: 06: 58.183Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + }), + }), + 'mobile_devices': dict({ + 'mobile_device': dict({ + '123456': dict({ + 'deviceMetadata': dict({ + 'locale': 'nl', + 'model': 'Samsung', + 'osVersion': '14', + 'platform': 'Android', + }), + 'id': 123456, + 'name': 'Home', + 'settings': dict({ + 'geoTrackingEnabled': False, + 'onDemandLogRetrievalEnabled': False, + 'pushNotifications': dict({ + 'awayModeReminder': True, + 'energyIqReminder': False, + 'energySavingsReportReminder': True, + 'homeModeReminder': True, + 'incidentDetection': True, + 'lowBatteryReminder': True, + 'openWindowReminder': True, + }), + 'specialOffersEnabled': False, + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 19acb0aecbd..2fd8e6a0468 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -1,20 +1,20 @@ """Test the Tado config flow.""" -from http import HTTPStatus from ipaddress import ip_address -from unittest.mock import MagicMock, patch +import threading +from unittest.mock import AsyncMock, MagicMock, patch -import PyTado +from PyTado.http import DeviceActivationStatus import pytest -import requests -from homeassistant import config_entries -from homeassistant.components.tado.config_flow import NoHomes +from homeassistant.components.tado.config_flow import TadoException from homeassistant.components.tado.const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, DOMAIN, ) +from homeassistant.config_entries import SOURCE_HOMEKIT, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -26,92 +26,186 @@ from homeassistant.helpers.service_info.zeroconf import ( from tests.common import MockConfigEntry -def _get_mock_tado_api(get_me=None) -> MagicMock: - mock_tado = MagicMock() - if isinstance(get_me, Exception): - type(mock_tado).get_me = MagicMock(side_effect=get_me) - else: - type(mock_tado).get_me = MagicMock(return_value=get_me) - return mock_tado +async def test_full_flow( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full flow of the config flow.""" + + event = threading.Event() + + def mock_tado_api_device_activation() -> None: + # Simulate the device activation process + event.wait(timeout=5) + + mock_tado_api.device_activation = mock_tado_api_device_activation + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + + event.set() + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_full_flow_reauth( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full flow of the config when reauthticating.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="ABC-123-DEF-456", + data={CONF_REFRESH_TOKEN: "totally_refresh_for_reauth"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # The no user input + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + event = threading.Event() + + def mock_tado_api_device_activation() -> None: + # Simulate the device activation process + event.wait(timeout=5) + + mock_tado_api.device_activation = mock_tado_api_device_activation + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + + event.set() + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + + +async def test_auth_timeout( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the auth timeout.""" + mock_tado_api.device_activation_status.return_value = DeviceActivationStatus.PENDING + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "timeout" + + mock_tado_api.device_activation_status.return_value = ( + DeviceActivationStatus.COMPLETED + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "timeout" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_no_homes(hass: HomeAssistant, mock_tado_api: MagicMock) -> None: + """Test the full flow of the config flow.""" + mock_tado_api.get_me.return_value["homes"] = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "finish_login" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_homes" + + +async def test_tado_creation(hass: HomeAssistant) -> None: + """Test we handle Form Exceptions.""" + + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=TadoException("Test exception"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" @pytest.mark.parametrize( ("exception", "error"), [ - (KeyError, "invalid_auth"), - (RuntimeError, "cannot_connect"), - (ValueError, "unknown"), + (Exception, "timeout"), + (TadoException, "timeout"), ], ) -async def test_form_exceptions( - hass: HomeAssistant, exception: Exception, error: str +async def test_wait_for_login_exception( + hass: HomeAssistant, + mock_tado_api: MagicMock, + exception: Exception, + error: str, ) -> None: - """Test we handle Form Exceptions.""" + """Test that an exception in wait for login is handled properly.""" + mock_tado_api.device_activation.side_effect = exception + result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": error} - - # Test a retry to recover, upon failure - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) - - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "myhome" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 + # @joostlek: I think the timeout step is not rightfully named, but heck, it works + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == error -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN, data={"username": "test-username"}) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init( - entry.entry_id, context={"source": config_entries.SOURCE_USER} - ) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -119,125 +213,17 @@ async def test_options_flow(hass: HomeAssistant) -> None: result["flow_id"], {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT} -async def test_create_entry(hass: HomeAssistant) -> None: - """Test we can setup though the user path.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) - - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "myhome" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - response_mock = MagicMock() - type(response_mock).status_code = HTTPStatus.UNAUTHORIZED - mock_tado_api = _get_mock_tado_api( - get_me=requests.HTTPError(response=response_mock) - ) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - response_mock = MagicMock() - type(response_mock).status_code = HTTPStatus.INTERNAL_SERVER_ERROR - mock_tado_api = _get_mock_tado_api( - get_me=requests.HTTPError(response=response_mock) - ) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_no_homes(hass: HomeAssistant) -> None: - """Test we handle no homes error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - mock_tado_api = _get_mock_tado_api(get_me={"homes": []}) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "no_homes"} - - -async def test_form_homekit(hass: HomeAssistant) -> None: +async def test_homekit(hass: HomeAssistant, mock_tado_api: MagicMock) -> None: """Test that we abort from homekit if tado is already setup.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, + context={"source": SOURCE_HOMEKIT}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], @@ -249,13 +235,18 @@ async def test_form_homekit(hass: HomeAssistant) -> None: ), ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - flow = next( - flow - for flow in hass.config_entries.flow.async_progress() - if flow["flow_id"] == result["flow_id"] - ) - assert flow["context"]["unique_id"] == "AA:BB:CC:DD:EE:FF" + assert result["step_id"] == "homekit_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "1" + + +async def test_homekit_already_setup( + hass: HomeAssistant, mock_tado_api: MagicMock +) -> None: + """Test that we abort from homekit if tado is already setup.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} @@ -264,7 +255,7 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, + context={"source": SOURCE_HOMEKIT}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], @@ -276,77 +267,4 @@ async def test_form_homekit(hass: HomeAssistant) -> None: ), ) assert result["type"] is FlowResultType.ABORT - - -@pytest.mark.parametrize( - ("exception", "error"), - [ - (PyTado.exceptions.TadoWrongCredentialsException, "invalid_auth"), - (RuntimeError, "cannot_connect"), - (NoHomes, "no_homes"), - (ValueError, "unknown"), - ], -) -async def test_reconfigure_flow( - hass: HomeAssistant, exception: Exception, error: str -) -> None: - """Test re-configuration flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "username": "test-username", - "password": "test-password", - "home_id": 1, - }, - unique_id="unique_id", - ) - entry.add_to_hass(hass) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.FORM - - with patch( - "homeassistant.components.tado.config_flow.Tado", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": error} - - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reconfigure_successful" - entry = hass.config_entries.async_get_entry(entry.entry_id) - assert entry - assert entry.title == "Mock Title" - assert entry.data == { - "username": "test-username", - "password": "test-password", - "home_id": 1, - } + assert result["reason"] == "already_configured" diff --git a/tests/components/tado/test_diagnostics.py b/tests/components/tado/test_diagnostics.py new file mode 100644 index 00000000000..3a4f04b0a4c --- /dev/null +++ b/tests/components/tado/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test the Tado component diagnostics.""" + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.tado.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + await async_init_integration(hass) + + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + assert diagnostics == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/tado/test_helper.py b/tests/components/tado/test_helper.py index da959c2124a..7f798e3797c 100644 --- a/tests/components/tado/test_helper.py +++ b/tests/components/tado/test_helper.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from PyTado.interface import Tado import pytest -from homeassistant.components.tado import TadoDataUpdateCoordinator +from homeassistant.components.tado import CONF_REFRESH_TOKEN, TadoDataUpdateCoordinator from homeassistant.components.tado.const import ( CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_DEFAULT, @@ -28,13 +28,13 @@ def entry(request: pytest.FixtureRequest) -> MockConfigEntry: request.param if hasattr(request, "param") else CONST_OVERLAY_TADO_DEFAULT ) return MockConfigEntry( - version=1, - minor_version=1, + version=2, domain=DOMAIN, title="Tado", data={ CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", + CONF_REFRESH_TOKEN: "test-refresh", }, options={ "fallback": fallback, diff --git a/tests/components/tado/test_init.py b/tests/components/tado/test_init.py new file mode 100644 index 00000000000..2f2ccacf3c0 --- /dev/null +++ b/tests/components/tado/test_init.py @@ -0,0 +1,30 @@ +"""Test the Tado integration.""" + +from homeassistant.components.tado import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_v1_migration(hass: HomeAssistant) -> None: + """Test migration from v1 to v2 config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test", + CONF_PASSWORD: "test", + }, + unique_id="1", + version=1, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.version == 2 + assert CONF_USERNAME not in entry.data + assert CONF_PASSWORD not in entry.data + + assert entry.state is ConfigEntryState.SETUP_ERROR + assert len(hass.config_entries.flow.async_progress()) == 1 diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 5bf87dbed33..6fd333dff51 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -2,8 +2,7 @@ import requests_mock -from homeassistant.components.tado import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -178,9 +177,16 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones/1/state", text=load_fixture(zone_1_state_fixture), ) + m.post( + "https://login.tado.com/oauth2/token", + text=load_fixture(token_fixture), + ) entry = MockConfigEntry( domain=DOMAIN, - data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}, + version=2, + data={ + CONF_REFRESH_TOKEN: "mock-token", + }, options={"fallback": "NEXT_TIME_BLOCK"}, ) entry.add_to_hass(hass) diff --git a/tests/components/tankerkoenig/const.py b/tests/components/tankerkoenig/const.py index 2c28753a7f3..9a2ecb3a2be 100644 --- a/tests/components/tankerkoenig/const.py +++ b/tests/components/tankerkoenig/const.py @@ -2,7 +2,7 @@ from aiotankerkoenig import PriceInfo, Station, Status -from homeassistant.components.tankerkoenig.const import CONF_FUEL_TYPES, CONF_STATIONS +from homeassistant.components.tankerkoenig.const import CONF_STATIONS from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -98,7 +98,6 @@ PRICES_MISSING_FUELTYPE = { CONFIG_DATA = { CONF_NAME: "Home", CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", - CONF_FUEL_TYPES: ["e5"], CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, CONF_RADIUS: 2.0, CONF_STATIONS: [ diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index b5b33d7c246..71d9d9c75f8 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -12,9 +12,6 @@ 'entry': dict({ 'data': dict({ 'api_key': '**REDACTED**', - 'fuel_types': list([ - 'e5', - ]), 'location': dict({ 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index bb1e943bbb9..967470c2c16 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -4,11 +4,7 @@ from unittest.mock import AsyncMock, patch from aiotankerkoenig.exceptions import TankerkoenigInvalidKeyError -from homeassistant.components.tankerkoenig.const import ( - CONF_FUEL_TYPES, - CONF_STATIONS, - DOMAIN, -) +from homeassistant.components.tankerkoenig.const import CONF_STATIONS, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, @@ -30,7 +26,6 @@ from tests.common import MockConfigEntry MOCK_USER_DATA = { CONF_NAME: "Home", CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", - CONF_FUEL_TYPES: ["e5"], CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, CONF_RADIUS: 2.0, } @@ -81,7 +76,6 @@ async def test_user(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_NAME] == "Home" assert result["data"][CONF_API_KEY] == "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx" - assert result["data"][CONF_FUEL_TYPES] == ["e5"] assert result["data"][CONF_LOCATION] == {"latitude": 51.0, "longitude": 13.0} assert result["data"][CONF_RADIUS] == 2.0 assert result["data"][CONF_STATIONS] == [ diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 4b259fabac2..2a99e00a9ce 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -82,6 +82,15 @@ OPTIMISTIC_TEMPLATE_ALARM_CONFIG = { "data": {"code": "{{ this.entity_id }}"}, }, } +EMPTY_ACTIONS = { + "arm_away": [], + "arm_home": [], + "arm_night": [], + "arm_vacation": [], + "arm_custom_bypass": [], + "disarm": [], + "trigger": [], +} TEMPLATE_ALARM_CONFIG = { @@ -173,6 +182,12 @@ async def test_setup_config_entry( "panels": {"test_template_panel": OPTIMISTIC_TEMPLATE_ALARM_CONFIG}, } }, + { + "alarm_control_panel": { + "platform": "template", + "panels": {"test_template_panel": EMPTY_ACTIONS}, + } + }, ], ) @pytest.mark.usefixtures("start_ha") diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index b201385240c..31239dbaf92 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -93,6 +93,45 @@ async def test_missing_optional_config(hass: HomeAssistant) -> None: _verify(hass, STATE_UNKNOWN) +async def test_missing_emtpy_press_action_config( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test: missing optional template is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "button": { + "press": [], + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_UNKNOWN) + + now = dt.datetime.now(dt.UTC) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {CONF_ENTITY_ID: _TEST_BUTTON}, + blocking=True, + ) + + _verify( + hass, + now.isoformat(), + ) + + async def test_missing_required_keys(hass: HomeAssistant) -> None: """Test: missing required fields will fail.""" with assert_setup_component(0, "template"): diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index c49db59c2ee..668592e388b 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -9,6 +9,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, + CoverEntityFeature, CoverState, ) from homeassistant.const import ( @@ -28,6 +29,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component from tests.common import assert_setup_component @@ -1123,3 +1125,50 @@ async def test_self_referencing_icon_with_no_template_is_not_a_loop( assert len(hass.states.async_all()) == 1 assert "Template loop detected" not in caplog.text + + +@pytest.mark.parametrize( + ("script", "supported_feature"), + [ + ("stop_cover", CoverEntityFeature.STOP), + ("set_cover_position", CoverEntityFeature.SET_POSITION), + ( + "set_cover_tilt_position", + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, + ), + ], +) +async def test_emtpy_action_config( + hass: HomeAssistant, script: str, supported_feature: CoverEntityFeature +) -> None: + """Test configuration with empty script.""" + with assert_setup_component(1, COVER_DOMAIN): + assert await async_setup_component( + hass, + COVER_DOMAIN, + { + COVER_DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "open_cover": [], + "close_cover": [], + script: [], + } + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("cover.test_template_cover") + assert ( + state.attributes["supported_features"] + == CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | supported_feature + ) diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 1a739b4921e..c0aade84e0f 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -556,6 +556,42 @@ async def setup_single_action_light( ) +@pytest.fixture +async def setup_empty_action_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + action: str, + extra_config: dict, +) -> None: + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + "turn_on": [], + "turn_off": [], + action: [], + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format( + hass, + count, + { + "name": "test_template_light", + "turn_on": [], + "turn_off": [], + action: [], + **extra_config, + }, + ) + + @pytest.fixture async def setup_light_with_effects( hass: HomeAssistant, @@ -2404,3 +2440,82 @@ async def test_nested_unique_id( entry = entity_registry.async_get("light.test_b") assert entry assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize(("count", "extra_config"), [(1, {})]) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ], +) +@pytest.mark.parametrize( + ("action", "color_mode"), + [ + ("set_level", ColorMode.BRIGHTNESS), + ("set_temperature", ColorMode.COLOR_TEMP), + ("set_hs", ColorMode.HS), + ("set_rgb", ColorMode.RGB), + ("set_rgbw", ColorMode.RGBW), + ("set_rgbww", ColorMode.RGBWW), + ], +) +async def test_empty_color_mode_action_config( + hass: HomeAssistant, + color_mode: ColorMode, + setup_empty_action_light, +) -> None: + """Test empty actions for color mode actions.""" + state = hass.states.get("light.test_template_light") + assert state.attributes["supported_color_modes"] == [color_mode] + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_template_light"}, + blocking=True, + ) + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_template_light"}, + blocking=True, + ) + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_OFF + + +@pytest.mark.parametrize(("count"), [1]) +@pytest.mark.parametrize( + ("style", "extra_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "effect_list_template": "{{ ['a'] }}", + "effect_template": "{{ 'a' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "effect_list": "{{ ['a'] }}", + "effect": "{{ 'a' }}", + }, + ), + ], +) +@pytest.mark.parametrize("action", ["set_effect"]) +async def test_effect_with_empty_action( + hass: HomeAssistant, + setup_empty_action_light, +) -> None: + """Test empty set_effect action.""" + state = hass.states.get("light.test_template_light") + assert state.attributes["supported_features"] == LightEntityFeature.EFFECT diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index d9cb294c41f..50baa11b2d0 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -4,7 +4,7 @@ import pytest from homeassistant import setup from homeassistant.components import lock -from homeassistant.components.lock import LockState +from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -15,6 +15,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall +from tests.common import assert_setup_component + OPTIMISTIC_LOCK_CONFIG = { "platform": "template", "lock": { @@ -718,3 +720,50 @@ async def test_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all("lock")) == 1 + + +async def test_emtpy_action_config(hass: HomeAssistant) -> None: + """Test configuration with empty script.""" + with assert_setup_component(1, lock.DOMAIN): + assert await setup.async_setup_component( + hass, + lock.DOMAIN, + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ 0 == 1 }}", + "lock": [], + "unlock": [], + "open": [], + "name": "test_template_lock", + "optimistic": True, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.attributes["supported_features"] == LockEntityFeature.OPEN + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.test_template_lock"}, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.state == LockState.UNLOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.test_template_lock"}, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.state == LockState.LOCKED diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index f73a943e752..5201541e2e0 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -1,8 +1,12 @@ """The tests for the Template number platform.""" +from typing import Any + +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import setup +from homeassistant.components import number, template from homeassistant.components.input_number import ( ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE, DOMAIN as INPUT_NUMBER_DOMAIN, @@ -18,6 +22,7 @@ from homeassistant.components.number import ( ) from homeassistant.components.template import DOMAIN from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, CONF_UNIT_OF_MEASUREMENT, @@ -25,10 +30,14 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import MockConfigEntry, assert_setup_component, async_capture_events -_TEST_NUMBER = "number.template_number" +_TEST_OBJECT_ID = "template_number" +_TEST_NUMBER = f"number.{_TEST_OBJECT_ID}" # Represent for number's value _VALUE_INPUT_NUMBER = "input_number.value" # Represent for number's minimum @@ -50,6 +59,38 @@ _VALUE_INPUT_NUMBER_CONFIG = { } +async def async_setup_modern_format( + hass: HomeAssistant, count: int, number_config: dict[str, Any] +) -> None: + """Do setup of number integration via new format.""" + config = {"template": {"number": number_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_number( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + number_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": _TEST_OBJECT_ID, **number_config} + ) + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -565,3 +606,36 @@ async def test_device_id( template_entity = entity_registry.async_get("number.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("count", "number_config"), + [ + ( + 1, + { + "state": "{{ 1 }}", + "set_value": [], + "step": "{{ 1 }}", + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.MODERN, + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_number) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + number.DOMAIN, + number.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _TEST_NUMBER, "value": 4}, + blocking=True, + ) + + state = hass.states.get(_TEST_NUMBER) + assert float(state.state) == 4 diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 59ab45aeb36..b2bc56af44a 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -1,8 +1,12 @@ """The tests for the Template select platform.""" +from typing import Any + +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import setup +from homeassistant.components import select, template from homeassistant.components.input_select import ( ATTR_OPTION as INPUT_SELECT_ATTR_OPTION, ATTR_OPTIONS as INPUT_SELECT_ATTR_OPTIONS, @@ -17,17 +21,53 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, ) from homeassistant.components.template import DOMAIN -from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import MockConfigEntry, assert_setup_component, async_capture_events -_TEST_SELECT = "select.template_select" +_TEST_OBJECT_ID = "template_select" +_TEST_SELECT = f"select.{_TEST_OBJECT_ID}" # Represent for select's current_option _OPTION_INPUT_SELECT = "input_select.option" +async def async_setup_modern_format( + hass: HomeAssistant, count: int, select_config: dict[str, Any] +) -> None: + """Do setup of select integration via new format.""" + config = {"template": {"select": select_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_select( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + select_config: dict[str, Any], +) -> None: + """Do setup of select integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": _TEST_OBJECT_ID, **select_config} + ) + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -527,3 +567,36 @@ async def test_device_id( template_entity = entity_registry.async_get("select.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "state": "{{ 'b' }}", + "select_option": [], + "options": "{{ ['a', 'b'] }}", + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.MODERN, + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "a"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "a" diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index f0dbe43b51e..43db93ac146 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -8,6 +8,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import switch, template from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.template.switch import rewrite_legacy_to_modern_conf +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -17,6 +18,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component @@ -29,6 +31,7 @@ from tests.common import ( mock_component, mock_restore_cache, ) +from tests.typing import WebSocketGenerator TEST_OBJECT_ID = "test_template_switch" TEST_ENTITY_ID = f"switch.{TEST_OBJECT_ID}" @@ -279,6 +282,46 @@ async def test_setup_config_entry( assert state == snapshot +@pytest.mark.parametrize("state_key", ["value_template", "state"]) +async def test_flow_preview( + hass: HomeAssistant, + state_key: str, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + result = await hass.config_entries.flow.async_init( + template.DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": SWITCH_DOMAIN}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SWITCH_DOMAIN + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", state_key: "{{ 'on' }}"}, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"]["state"] == "on" + + @pytest.mark.parametrize( ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] ) @@ -938,3 +981,49 @@ async def test_device_id( template_entity = entity_registry.async_get("switch.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "switch_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + TEST_OBJECT_ID: { + "turn_on": [], + "turn_off": [], + }, + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "turn_on": [], + "turn_off": [], + }, + ), + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_switch) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 6053a2bd9ec..cc5bc9b39e3 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -1,18 +1,29 @@ """The tests for the Template vacuum platform.""" +from typing import Any + import pytest from homeassistant import setup -from homeassistant.components.vacuum import ATTR_BATTERY_LEVEL, VacuumActivity +from homeassistant.components import vacuum +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, + VacuumActivity, + VacuumEntityFeature, +) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import assert_setup_component from tests.components.vacuum import common -_TEST_VACUUM = "vacuum.test_vacuum" +_TEST_OBJECT_ID = "test_vacuum" +_TEST_VACUUM = f"vacuum.{_TEST_OBJECT_ID}" _STATE_INPUT_SELECT = "input_select.state" _SPOT_CLEANING_INPUT_BOOLEAN = "input_boolean.spot_cleaning" _LOCATING_INPUT_BOOLEAN = "input_boolean.locating" @@ -20,6 +31,50 @@ _FAN_SPEED_INPUT_SELECT = "input_select.fan_speed" _BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] +) -> None: + """Do setup of number integration via new format.""" + config = {"vacuum": {"platform": "template", "vacuums": vacuum_config}} + + with assert_setup_component(count, vacuum.DOMAIN): + assert await async_setup_component( + hass, + vacuum.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + vacuum_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, vacuum_config) + + +@pytest.fixture +async def setup_test_vacuum_with_extra_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + vacuum_config: dict[str, Any], + extra_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + config = {_TEST_OBJECT_ID: {**vacuum_config, **extra_config}} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, config) + + @pytest.mark.parametrize(("count", "domain"), [(1, "vacuum")]) @pytest.mark.parametrize( ("parm1", "parm2", "config"), @@ -697,3 +752,71 @@ async def _register_components(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "vacuum_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "start": [], + }, + ), + ], +) +@pytest.mark.parametrize( + ("extra_config", "supported_features"), + [ + ( + { + "pause": [], + }, + VacuumEntityFeature.PAUSE, + ), + ( + { + "stop": [], + }, + VacuumEntityFeature.STOP, + ), + ( + { + "return_to_base": [], + }, + VacuumEntityFeature.RETURN_HOME, + ), + ( + { + "clean_spot": [], + }, + VacuumEntityFeature.CLEAN_SPOT, + ), + ( + { + "locate": [], + }, + VacuumEntityFeature.LOCATE, + ), + ( + { + "set_fan_speed": [], + }, + VacuumEntityFeature.FAN_SPEED, + ), + ], +) +async def test_empty_action_config( + hass: HomeAssistant, + supported_features: VacuumEntityFeature, + setup_test_vacuum_with_extra_config, +) -> None: + """Test configuration with empty script.""" + await common.async_start(hass, _TEST_VACUUM) + await hass.async_block_till_done() + + state = hass.states.get(_TEST_VACUUM) + assert state.attributes["supported_features"] == ( + VacuumEntityFeature.STATE | VacuumEntityFeature.START | supported_features + ) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 081028b6f5b..5db6a000ccc 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -928,3 +928,65 @@ async def test_trigger_entity_restore_state_fail( state = hass.states.get("weather.test") assert state.state == STATE_UNKNOWN assert state.attributes.get("temperature") is None + + +async def test_new_style_template_state_text(hass: HomeAssistant) -> None: + """Test the state text of a template.""" + assert await async_setup_component( + hass, + "weather", + { + "weather": [ + {"weather": {"platform": "demo"}}, + ] + }, + ) + assert await async_setup_component( + hass, + "template", + { + "template": { + "weather": { + "name": "test", + "attribution_template": "{{ states('sensor.attribution') }}", + "condition_template": "sunny", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + "pressure_template": "{{ states('sensor.pressure') }}", + "wind_speed_template": "{{ states('sensor.windspeed') }}", + "wind_bearing_template": "{{ states('sensor.windbearing') }}", + "ozone_template": "{{ states('sensor.ozone') }}", + "visibility_template": "{{ states('sensor.visibility') }}", + "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", + "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", + "dew_point_template": "{{ states('sensor.dew_point') }}", + "apparent_temperature_template": "{{ states('sensor.apparent_temperature') }}", + }, + }, + }, + ) + + for attr, v_attr, value in ( + ( + "sensor.attribution", + ATTR_ATTRIBUTION, + "The custom attribution", + ), + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ("sensor.pressure", ATTR_WEATHER_PRESSURE, 1000), + ("sensor.windspeed", ATTR_WEATHER_WIND_SPEED, 20), + ("sensor.windbearing", ATTR_WEATHER_WIND_BEARING, 180), + ("sensor.ozone", ATTR_WEATHER_OZONE, 25), + ("sensor.visibility", ATTR_WEATHER_VISIBILITY, 4.6), + ("sensor.wind_gust_speed", ATTR_WEATHER_WIND_GUST_SPEED, 30), + ("sensor.cloud_coverage", ATTR_WEATHER_CLOUD_COVERAGE, 75), + ("sensor.dew_point", ATTR_WEATHER_DEW_POINT, 2.2), + ("sensor.apparent_temperature", ATTR_WEATHER_APPARENT_TEMPERATURE, 25), + ): + hass.states.async_set(attr, value) + await hass.async_block_till_done() + state = hass.states.get("weather.test") + assert state is not None + assert state.state == "sunny" + assert state.attributes.get(v_attr) == value diff --git a/tests/components/tesla_fleet/conftest.py b/tests/components/tesla_fleet/conftest.py index 06d2b54c936..10b01caca96 100644 --- a/tests/components/tesla_fleet/conftest.py +++ b/tests/components/tesla_fleet/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures for Tessie.""" +"""Fixtures for Tesla Fleet.""" from __future__ import annotations @@ -113,7 +113,7 @@ def mock_products() -> Generator[AsyncMock]: def mock_vehicle_state() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.vehicle", + "tesla_fleet_api.tesla.VehicleFleet.vehicle", return_value=VEHICLE_ONLINE, ) as mock_vehicle: yield mock_vehicle @@ -123,7 +123,7 @@ def mock_vehicle_state() -> Generator[AsyncMock]: def mock_vehicle_data() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle_data method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.vehicle_data", + "tesla_fleet_api.tesla.VehicleFleet.vehicle_data", return_value=VEHICLE_DATA, ) as mock_vehicle_data: yield mock_vehicle_data @@ -133,7 +133,7 @@ def mock_vehicle_data() -> Generator[AsyncMock]: def mock_wake_up() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific wake_up method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.wake_up", + "tesla_fleet_api.tesla.VehicleFleet.wake_up", return_value=VEHICLE_ONLINE, ) as mock_wake_up: yield mock_wake_up @@ -143,7 +143,7 @@ def mock_wake_up() -> Generator[AsyncMock]: def mock_live_status() -> Generator[AsyncMock]: """Mock Tesla Fleet API Energy Specific live_status method.""" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.live_status", + "tesla_fleet_api.tesla.EnergySite.live_status", side_effect=lambda: deepcopy(LIVE_STATUS), ) as mock_live_status: yield mock_live_status @@ -153,7 +153,7 @@ def mock_live_status() -> Generator[AsyncMock]: def mock_site_info() -> Generator[AsyncMock]: """Mock Tesla Fleet API Energy Specific site_info method.""" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.site_info", + "tesla_fleet_api.tesla.EnergySite.site_info", side_effect=lambda: deepcopy(SITE_INFO), ) as mock_live_status: yield mock_live_status @@ -182,7 +182,7 @@ def mock_request(): def mock_energy_history(): """Mock Teslemetry Energy Specific site_info method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.energy_history", + "tesla_fleet_api.tesla.EnergySite.energy_history", return_value=ENERGY_HISTORY, ) as mock_live_status: yield mock_live_status @@ -192,7 +192,7 @@ def mock_energy_history(): def mock_signed_command() -> Generator[AsyncMock]: """Mock Tesla Fleet Api signed_command method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSigned.signed_command", + "tesla_fleet_api.tesla.VehicleSigned.signed_command", return_value=COMMAND_OK, ) as mock_signed_command: yield mock_signed_command diff --git a/tests/components/tesla_fleet/test_button.py b/tests/components/tesla_fleet/test_button.py index ef1cfd90357..d43f7448379 100644 --- a/tests/components/tesla_fleet/test_button.py +++ b/tests/components/tesla_fleet/test_button.py @@ -56,7 +56,7 @@ async def test_press( await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) with patch( - f"homeassistant.components.tesla_fleet.VehicleSpecific.{func}", + f"tesla_fleet_api.tesla.VehicleFleet.{func}", return_value=COMMAND_OK, ) as command: await hass.services.async_call( @@ -85,7 +85,7 @@ async def test_press_signing_error( with ( patch("homeassistant.components.tesla_fleet.TeslaFleetApi.get_private_key"), patch( - "homeassistant.components.tesla_fleet.VehicleSigned.flash_lights", + "tesla_fleet_api.tesla.VehicleSigned.flash_lights", side_effect=NotOnWhitelistFault, ), pytest.raises(HomeAssistantError) as error, diff --git a/tests/components/tesla_fleet/test_climate.py b/tests/components/tesla_fleet/test_climate.py index b45e5259a5c..fae79c795c2 100644 --- a/tests/components/tesla_fleet/test_climate.py +++ b/tests/components/tesla_fleet/test_climate.py @@ -257,7 +257,7 @@ async def test_invalid_error( with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", side_effect=InvalidCommand, ) as mock_on, pytest.raises( @@ -285,7 +285,7 @@ async def test_errors( with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=response, ) as mock_on, pytest.raises(HomeAssistantError), @@ -308,7 +308,7 @@ async def test_ignored_error( await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) entity_id = "climate.test_climate" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=COMMAND_IGNORED_REASON, ) as mock_on: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_cover.py b/tests/components/tesla_fleet/test_cover.py index ac5307b2fdd..15d14f34a87 100644 --- a/tests/components/tesla_fleet/test_cover.py +++ b/tests/components/tesla_fleet/test_cover.py @@ -89,7 +89,7 @@ async def test_cover_services( # Vent Windows entity_id = "cover.test_windows" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.window_control", + "tesla_fleet_api.tesla.VehicleFleet.window_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -118,7 +118,7 @@ async def test_cover_services( # Charge Port Door entity_id = "cover.test_charge_port_door" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.tesla.VehicleFleet.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -133,7 +133,7 @@ async def test_cover_services( assert state.state == CoverState.OPEN with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_close", + "tesla_fleet_api.tesla.VehicleFleet.charge_port_door_close", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -150,7 +150,7 @@ async def test_cover_services( # Frunk entity_id = "cover.test_frunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.tesla.VehicleFleet.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -167,7 +167,7 @@ async def test_cover_services( # Trunk entity_id = "cover.test_trunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.tesla.VehicleFleet.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -196,7 +196,7 @@ async def test_cover_services( # Sunroof entity_id = "cover.test_sunroof" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.sun_roof_control", + "tesla_fleet_api.tesla.VehicleFleet.sun_roof_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index ff103ce03c2..7bd90a3568c 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -1,6 +1,7 @@ """Test the Tesla Fleet init.""" from copy import deepcopy +from datetime import timedelta from unittest.mock import AsyncMock, patch from aiohttp import RequestInfo @@ -231,57 +232,58 @@ async def test_vehicle_sleep( freezer: FrozenDateTimeFactory, ) -> None: """Test coordinator refresh with an error.""" - await setup_platform(hass, normal_config_entry) - assert mock_vehicle_data.call_count == 1 - freezer.tick(VEHICLE_WAIT + VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Let vehicle sleep, no updates for 15 minutes - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 + TEST_INTERVAL = timedelta(seconds=120) - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # No polling, call_count should not increase - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 + with patch( + "homeassistant.components.tesla_fleet.coordinator.VEHICLE_INTERVAL", + TEST_INTERVAL, + ): + await setup_platform(hass, normal_config_entry) + assert mock_vehicle_data.call_count == 1 - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # No polling, call_count should not increase - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 + freezer.tick(VEHICLE_WAIT + TEST_INTERVAL) + async_fire_time_changed(hass) + # Let vehicle sleep, no updates for 15 minutes + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 2 - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Vehicle didn't sleep, go back to normal - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 3 + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # No polling, call_count should not increase + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 2 - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Regular polling - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 4 + freezer.tick(VEHICLE_WAIT) + async_fire_time_changed(hass) + # Vehicle didn't sleep, go back to normal + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 3 - mock_vehicle_data.return_value = VEHICLE_DATA_ALT - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Vehicle active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 5 + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Regular polling + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 4 - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Dont let sleep when active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 6 + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Vehicle active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 5 - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Dont let sleep when active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 7 + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Dont let sleep when active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 6 + + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Dont let sleep when active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 7 # Test Energy Live Coordinator diff --git a/tests/components/tesla_fleet/test_lock.py b/tests/components/tesla_fleet/test_lock.py index 00b77aefcaf..ac9a7b49b55 100644 --- a/tests/components/tesla_fleet/test_lock.py +++ b/tests/components/tesla_fleet/test_lock.py @@ -59,7 +59,7 @@ async def test_lock_services( entity_id = "lock.test_lock" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.door_lock", + "tesla_fleet_api.tesla.VehicleFleet.door_lock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -73,7 +73,7 @@ async def test_lock_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.door_unlock", + "tesla_fleet_api.tesla.VehicleFleet.door_unlock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -97,7 +97,7 @@ async def test_lock_services( ) with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.tesla.VehicleFleet.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_media_player.py b/tests/components/tesla_fleet/test_media_player.py index 4c833e7499f..b2900d96c80 100644 --- a/tests/components/tesla_fleet/test_media_player.py +++ b/tests/components/tesla_fleet/test_media_player.py @@ -88,7 +88,7 @@ async def test_media_player_services( entity_id = "media_player.test_media_player" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.adjust_volume", + "tesla_fleet_api.tesla.VehicleFleet.adjust_volume", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -102,7 +102,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.tesla.VehicleFleet.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -117,7 +117,7 @@ async def test_media_player_services( # This test will fail without the previous call to pause playback with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.tesla.VehicleFleet.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -131,7 +131,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_next_track", + "tesla_fleet_api.tesla.VehicleFleet.media_next_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -144,7 +144,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_prev_track", + "tesla_fleet_api.tesla.VehicleFleet.media_prev_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_number.py b/tests/components/tesla_fleet/test_number.py index 8551a99ee29..4ade98852c8 100644 --- a/tests/components/tesla_fleet/test_number.py +++ b/tests/components/tesla_fleet/test_number.py @@ -57,7 +57,7 @@ async def test_number_services( entity_id = "number.test_charge_current" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.set_charging_amps", + "tesla_fleet_api.tesla.VehicleFleet.set_charging_amps", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -72,7 +72,7 @@ async def test_number_services( entity_id = "number.test_charge_limit" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.set_charge_limit", + "tesla_fleet_api.tesla.VehicleFleet.set_charge_limit", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -87,7 +87,7 @@ async def test_number_services( entity_id = "number.energy_site_backup_reserve" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.backup", + "tesla_fleet_api.tesla.EnergySite.backup", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -105,7 +105,7 @@ async def test_number_services( entity_id = "number.energy_site_off_grid_reserve" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.off_grid_vehicle_charging_reserve", + "tesla_fleet_api.tesla.EnergySite.off_grid_vehicle_charging_reserve", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_select.py b/tests/components/tesla_fleet/test_select.py index 902b28ddb7a..f06d67041c9 100644 --- a/tests/components/tesla_fleet/test_select.py +++ b/tests/components/tesla_fleet/test_select.py @@ -61,11 +61,11 @@ async def test_select_services( entity_id = "select.test_seat_heater_front_left" with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.remote_seat_heater_request", + "tesla_fleet_api.tesla.VehicleFleet.remote_seat_heater_request", return_value=COMMAND_OK, ) as remote_seat_heater_request, patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=COMMAND_OK, ) as auto_conditioning_start, ): @@ -83,11 +83,11 @@ async def test_select_services( entity_id = "select.test_steering_wheel_heater" with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.remote_steering_wheel_heat_level_request", + "tesla_fleet_api.tesla.VehicleFleet.remote_steering_wheel_heat_level_request", return_value=COMMAND_OK, ) as remote_steering_wheel_heat_level_request, patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=COMMAND_OK, ) as auto_conditioning_start, ): @@ -104,7 +104,7 @@ async def test_select_services( entity_id = "select.energy_site_operation_mode" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.operation", + "tesla_fleet_api.tesla.EnergySite.operation", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -122,7 +122,7 @@ async def test_select_services( entity_id = "select.energy_site_allow_export" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.grid_import_export", + "tesla_fleet_api.tesla.EnergySite.grid_import_export", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_switch.py b/tests/components/tesla_fleet/test_switch.py index fba4fc05cc4..022c3a0ab18 100644 --- a/tests/components/tesla_fleet/test_switch.py +++ b/tests/components/tesla_fleet/test_switch.py @@ -71,41 +71,41 @@ async def test_switch_offline( @pytest.mark.parametrize( ("name", "on", "off"), [ - ("test_charge", "VehicleSpecific.charge_start", "VehicleSpecific.charge_stop"), + ("test_charge", "VehicleFleet.charge_start", "VehicleFleet.charge_stop"), ( "test_auto_seat_climate_left", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", ), ( "test_auto_seat_climate_right", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", ), ( "test_auto_steering_wheel_heater", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", + "VehicleFleet.remote_auto_steering_wheel_heat_climate_request", + "VehicleFleet.remote_auto_steering_wheel_heat_climate_request", ), ( "test_defrost", - "VehicleSpecific.set_preconditioning_max", - "VehicleSpecific.set_preconditioning_max", + "VehicleFleet.set_preconditioning_max", + "VehicleFleet.set_preconditioning_max", ), ( "energy_site_storm_watch", - "EnergySpecific.storm_mode", - "EnergySpecific.storm_mode", + "EnergySite.storm_mode", + "EnergySite.storm_mode", ), ( "energy_site_allow_charging_from_grid", - "EnergySpecific.grid_import_export", - "EnergySpecific.grid_import_export", + "EnergySite.grid_import_export", + "EnergySite.grid_import_export", ), ( "test_sentry_mode", - "VehicleSpecific.set_sentry_mode", - "VehicleSpecific.set_sentry_mode", + "VehicleFleet.set_sentry_mode", + "VehicleFleet.set_sentry_mode", ), ], ) @@ -122,7 +122,7 @@ async def test_switch_services( entity_id = f"switch.{name}" with patch( - f"homeassistant.components.tesla_fleet.{on}", + f"tesla_fleet_api.tesla.{on}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -136,7 +136,7 @@ async def test_switch_services( call.assert_called_once() with patch( - f"homeassistant.components.tesla_fleet.{off}", + f"tesla_fleet_api.tesla.{off}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index e89bab9eff1..0152543e512 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -25,7 +25,7 @@ from .const import ( def mock_metadata(): """Mock Tesla Fleet Api metadata method.""" with patch( - "homeassistant.components.teslemetry.Teslemetry.metadata", return_value=METADATA + "tesla_fleet_api.teslemetry.Teslemetry.metadata", return_value=METADATA ) as mock_products: yield mock_products @@ -34,7 +34,7 @@ def mock_metadata(): def mock_products(): """Mock Tesla Fleet Api products method.""" with patch( - "homeassistant.components.teslemetry.Teslemetry.products", return_value=PRODUCTS + "tesla_fleet_api.teslemetry.Teslemetry.products", return_value=PRODUCTS ) as mock_products: yield mock_products @@ -43,7 +43,7 @@ def mock_products(): def mock_vehicle_data() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle_data method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.vehicle_data", + "tesla_fleet_api.teslemetry.Vehicle.vehicle_data", return_value=VEHICLE_DATA, ) as mock_vehicle_data: yield mock_vehicle_data @@ -53,7 +53,7 @@ def mock_vehicle_data() -> Generator[AsyncMock]: def mock_legacy(): """Mock Tesla Fleet Api products method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True + "tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True ) as mock_pre2021: yield mock_pre2021 @@ -62,7 +62,7 @@ def mock_legacy(): def mock_wake_up(): """Mock Tesla Fleet API Vehicle Specific wake_up method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.wake_up", + "tesla_fleet_api.teslemetry.Vehicle.wake_up", return_value=WAKE_UP_ONLINE, ) as mock_wake_up: yield mock_wake_up @@ -72,7 +72,7 @@ def mock_wake_up(): def mock_vehicle() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.vehicle", + "tesla_fleet_api.teslemetry.Vehicle.vehicle", return_value=WAKE_UP_ONLINE, ) as mock_vehicle: yield mock_vehicle @@ -82,7 +82,7 @@ def mock_vehicle() -> Generator[AsyncMock]: def mock_request(): """Mock Tesla Fleet API Vehicle Specific class.""" with patch( - "homeassistant.components.teslemetry.Teslemetry._request", + "tesla_fleet_api.teslemetry.Teslemetry._request", return_value=COMMAND_OK, ) as mock_request: yield mock_request @@ -92,7 +92,7 @@ def mock_request(): def mock_live_status(): """Mock Teslemetry Energy Specific live_status method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.live_status", + "tesla_fleet_api.tesla.energysite.EnergySite.live_status", side_effect=lambda: deepcopy(LIVE_STATUS), ) as mock_live_status: yield mock_live_status @@ -102,7 +102,7 @@ def mock_live_status(): def mock_site_info(): """Mock Teslemetry Energy Specific site_info method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.site_info", + "tesla_fleet_api.tesla.energysite.EnergySite.site_info", side_effect=lambda: deepcopy(SITE_INFO), ) as mock_live_status: yield mock_live_status @@ -112,7 +112,7 @@ def mock_site_info(): def mock_energy_history(): """Mock Teslemetry Energy Specific site_info method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.energy_history", + "tesla_fleet_api.tesla.energysite.EnergySite.energy_history", return_value=ENERGY_HISTORY, ) as mock_live_status: yield mock_live_status @@ -122,7 +122,7 @@ def mock_energy_history(): def mock_add_listener(): """Mock Teslemetry Stream listen method.""" with patch( - "homeassistant.components.teslemetry.TeslemetryStream.async_add_listener", + "teslemetry_stream.TeslemetryStream.async_add_listener", ) as mock_add_listener: mock_add_listener.listeners = [] @@ -165,7 +165,7 @@ def mock_stream_update_config(): def mock_stream_connected(): """Mock Teslemetry Stream listen method.""" with patch( - "homeassistant.components.teslemetry.TeslemetryStream.connected", + "teslemetry_stream.TeslemetryStream.connected", return_value=True, ) as mock_stream_connected: yield mock_stream_connected diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 4c265c00cb8..e0e68f23c79 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -1,10 +1,4 @@ # serializer version: 1 -# name: test_asleep_or_offline[HomeAssistantError] - 'Timed out trying to wake up vehicle' -# --- -# name: test_asleep_or_offline[InvalidCommand] - 'Failed to wake up vehicle: The data request or command is unknown.' -# --- # name: test_climate[climate.test_cabin_overheat_protection-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -78,6 +72,10 @@ }), 'area_id': None, 'capabilities': dict({ + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'hvac_modes': list([ , , @@ -113,7 +111,7 @@ 'original_name': 'Climate', 'platform': 'teslemetry', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, @@ -123,6 +121,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 30.0, + 'fan_mode': 'off', + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'friendly_name': 'Test Climate', 'hvac_modes': list([ , @@ -137,7 +140,7 @@ 'dog', 'camp', ]), - 'supported_features': , + 'supported_features': , 'temperature': 22.0, }), 'context': , @@ -220,6 +223,10 @@ }), 'area_id': None, 'capabilities': dict({ + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'hvac_modes': list([ , , @@ -255,7 +262,7 @@ 'original_name': 'Climate', 'platform': 'teslemetry', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, @@ -265,6 +272,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 30.0, + 'fan_mode': 'off', + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'friendly_name': 'Test Climate', 'hvac_modes': list([ , @@ -279,7 +291,7 @@ 'dog', 'camp', ]), - 'supported_features': , + 'supported_features': , 'temperature': 22.0, }), 'context': , @@ -297,7 +309,9 @@ 'area_id': None, 'capabilities': dict({ 'hvac_modes': list([ + , , + , ]), 'max_temp': 40, 'min_temp': 30, @@ -339,6 +353,7 @@ 'capabilities': dict({ 'hvac_modes': list([ , + , ]), 'max_temp': 28.0, 'min_temp': 15.0, @@ -374,3 +389,85 @@ # name: test_invalid_error[error] 'Command returned exception: The data request or command is unknown.' # --- +# name: test_select_streaming[climate.test_cabin_overheat_protection] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Cabin overheat protection', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'supported_features': , + 'target_temp_step': 5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_select_streaming[climate.test_climate LHD] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26.0, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': None, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': , + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_select_streaming[climate.test_climate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26.0, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': None, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': , + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/teslemetry/test_button.py b/tests/components/teslemetry/test_button.py index 75f94342f1e..46db33ce913 100644 --- a/tests/components/teslemetry/test_button.py +++ b/tests/components/teslemetry/test_button.py @@ -42,7 +42,7 @@ async def test_press(hass: HomeAssistant, name: str, func: str) -> None: await setup_platform(hass, [Platform.BUTTON]) with patch( - f"homeassistant.components.teslemetry.VehicleSpecific.{func}", + f"tesla_fleet_api.teslemetry.Vehicle.{func}", return_value=COMMAND_OK, ) as command: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 33f2e134806..27bed45c51f 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -2,10 +2,10 @@ from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import InvalidCommand +from teslemetry_stream import Signal from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -24,15 +24,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import ( COMMAND_ERRORS, COMMAND_IGNORED_REASON, METADATA_NOSCOPE, VEHICLE_DATA_ALT, - VEHICLE_DATA_ASLEEP, - WAKE_UP_ASLEEP, - WAKE_UP_ONLINE, ) @@ -41,6 +38,7 @@ async def test_climate( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" @@ -195,6 +193,7 @@ async def test_climate_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" @@ -211,7 +210,7 @@ async def test_invalid_error(hass: HomeAssistant, snapshot: SnapshotAssertion) - with ( patch( - "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.teslemetry.Vehicle.auto_conditioning_start", side_effect=InvalidCommand, ) as mock_on, pytest.raises(HomeAssistantError) as error, @@ -235,7 +234,7 @@ async def test_errors(hass: HomeAssistant, response: str) -> None: with ( patch( - "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.teslemetry.Vehicle.auto_conditioning_start", return_value=response, ) as mock_on, pytest.raises(HomeAssistantError), @@ -257,7 +256,7 @@ async def test_ignored_error( await setup_platform(hass, [Platform.CLIMATE]) entity_id = "climate.test_climate" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.teslemetry.Vehicle.auto_conditioning_start", return_value=COMMAND_IGNORED_REASON, ) as mock_on: await hass.services.async_call( @@ -269,71 +268,12 @@ async def test_ignored_error( mock_on.assert_called_once() -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_asleep_or_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, - mock_wake_up: AsyncMock, - mock_vehicle: AsyncMock, - freezer: FrozenDateTimeFactory, - snapshot: SnapshotAssertion, -) -> None: - """Tests asleep is handled.""" - - mock_vehicle_data.return_value = VEHICLE_DATA_ASLEEP - await setup_platform(hass, [Platform.CLIMATE]) - entity_id = "climate.test_climate" - - # Run a command but fail trying to wake up the vehicle - mock_wake_up.side_effect = InvalidCommand - with pytest.raises(HomeAssistantError) as error: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - assert str(error.value) == snapshot(name="InvalidCommand") - mock_wake_up.assert_called_once() - - mock_wake_up.side_effect = None - mock_wake_up.reset_mock() - - # Run a command but timeout trying to wake up the vehicle - mock_wake_up.return_value = WAKE_UP_ASLEEP - mock_vehicle.return_value = WAKE_UP_ASLEEP - with ( - patch("homeassistant.components.teslemetry.helpers.asyncio.sleep"), - pytest.raises(HomeAssistantError) as error, - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - assert str(error.value) == snapshot(name="HomeAssistantError") - mock_wake_up.assert_called_once() - mock_vehicle.assert_called() - - mock_wake_up.reset_mock() - mock_vehicle.reset_mock() - mock_wake_up.return_value = WAKE_UP_ONLINE - mock_vehicle.return_value = WAKE_UP_ONLINE - - # Run a command and wake up the vehicle immediately - await hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [entity_id]}, blocking=True - ) - await hass.async_block_till_done() - mock_wake_up.assert_called_once() - - async def test_climate_noscope( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" mock_metadata.return_value = METADATA_NOSCOPE @@ -363,3 +303,47 @@ async def test_climate_noscope( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, blocking=True, ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the select entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.CLIMATE]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.INSIDE_TEMP: 26, + Signal.HVAC_AC_ENABLED: True, + Signal.CLIMATE_KEEPER_MODE: "ClimateKeeperModeOn", + Signal.RIGHT_HAND_DRIVE: True, + Signal.HVAC_LEFT_TEMPERATURE_REQUEST: 22, + Signal.HVAC_RIGHT_TEMPERATURE_REQUEST: 21, + Signal.CABIN_OVERHEAT_PROTECTION_MODE: "CabinOverheatProtectionModeStateOn", + Signal.CABIN_OVERHEAT_PROTECTION_TEMPERATURE_LIMIT: 35, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + assert hass.states.get("climate.test_climate") == snapshot( + name="climate.test_climate LHD" + ) + + await reload_platform(hass, entry, [Platform.CLIMATE]) + + # Assert the entities restored their values + for entity_id in ( + "climate.test_climate", + "climate.test_cabin_overheat_protection", + ): + assert hass.states.get(entity_id) == snapshot(name=entity_id) diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 14af1e732fe..e3933931c9f 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -75,7 +75,7 @@ async def test_cover_services( # Vent Windows entity_id = "cover.test_windows" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.window_control", + "tesla_fleet_api.teslemetry.Vehicle.window_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -104,7 +104,7 @@ async def test_cover_services( # Charge Port Door entity_id = "cover.test_charge_port_door" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.teslemetry.Vehicle.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -119,7 +119,7 @@ async def test_cover_services( assert state.state == CoverState.OPEN with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_close", + "tesla_fleet_api.teslemetry.Vehicle.charge_port_door_close", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -136,7 +136,7 @@ async def test_cover_services( # Frunk entity_id = "cover.test_frunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.teslemetry.Vehicle.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -153,7 +153,7 @@ async def test_cover_services( # Trunk entity_id = "cover.test_trunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.teslemetry.Vehicle.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -182,7 +182,7 @@ async def test_cover_services( # Sunroof entity_id = "cover.test_sunroof" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.sun_roof_control", + "tesla_fleet_api.teslemetry.Vehicle.sun_roof_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 5481e6cc034..fcf9c76c939 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -2,17 +2,14 @@ from unittest.mock import AsyncMock -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import ( InvalidToken, SubscriptionRequired, TeslaFleetError, - VehicleOffline, ) -from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, Platform @@ -22,8 +19,6 @@ from homeassistant.helpers import device_registry as dr from . import setup_platform from .const import VEHICLE_DATA_ALT -from tests.common import async_fire_time_changed - ERRORS = [ (InvalidToken, ConfigEntryState.SETUP_ERROR), (SubscriptionRequired, ConfigEntryState.SETUP_ERROR), @@ -69,22 +64,6 @@ async def test_devices( assert device == snapshot(name=f"{device.identifiers}") -async def test_vehicle_refresh_offline( - hass: HomeAssistant, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory -) -> None: - """Test coordinator refresh with an error.""" - entry = await setup_platform(hass, [Platform.CLIMATE]) - assert entry.state is ConfigEntryState.LOADED - mock_vehicle_data.assert_called_once() - mock_vehicle_data.reset_mock() - - mock_vehicle_data.side_effect = VehicleOffline - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - mock_vehicle_data.assert_called_once() - - @pytest.mark.parametrize(("side_effect", "state"), ERRORS) async def test_vehicle_refresh_error( hass: HomeAssistant, diff --git a/tests/components/teslemetry/test_lock.py b/tests/components/teslemetry/test_lock.py index 848eee82c39..a74d613859f 100644 --- a/tests/components/teslemetry/test_lock.py +++ b/tests/components/teslemetry/test_lock.py @@ -57,7 +57,7 @@ async def test_lock_services( entity_id = "lock.test_lock" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.door_lock", + "tesla_fleet_api.teslemetry.Vehicle.door_lock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -71,7 +71,7 @@ async def test_lock_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.door_unlock", + "tesla_fleet_api.teslemetry.Vehicle.door_unlock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -95,7 +95,7 @@ async def test_lock_services( ) with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.teslemetry.Vehicle.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py index de990dbe7bc..ab8f21ceda4 100644 --- a/tests/components/teslemetry/test_media_player.py +++ b/tests/components/teslemetry/test_media_player.py @@ -76,7 +76,7 @@ async def test_media_player_services( entity_id = "media_player.test_media_player" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.adjust_volume", + "tesla_fleet_api.teslemetry.Vehicle.adjust_volume", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -90,7 +90,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.teslemetry.Vehicle.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -105,7 +105,7 @@ async def test_media_player_services( # This test will fail without the previous call to pause playback with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.teslemetry.Vehicle.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -119,7 +119,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_next_track", + "tesla_fleet_api.teslemetry.Vehicle.media_next_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -132,7 +132,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_prev_track", + "tesla_fleet_api.teslemetry.Vehicle.media_prev_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_number.py b/tests/components/teslemetry/test_number.py index 95eed5a3f1e..2c45631a060 100644 --- a/tests/components/teslemetry/test_number.py +++ b/tests/components/teslemetry/test_number.py @@ -42,7 +42,7 @@ async def test_number_services( entity_id = "number.test_charge_current" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_charging_amps", + "tesla_fleet_api.teslemetry.Vehicle.set_charging_amps", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -57,7 +57,7 @@ async def test_number_services( entity_id = "number.test_charge_limit" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_charge_limit", + "tesla_fleet_api.teslemetry.Vehicle.set_charge_limit", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -72,7 +72,7 @@ async def test_number_services( entity_id = "number.energy_site_backup_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.backup", + "tesla_fleet_api.teslemetry.EnergySite.backup", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -90,7 +90,7 @@ async def test_number_services( entity_id = "number.energy_site_off_grid_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.off_grid_vehicle_charging_reserve", + "tesla_fleet_api.teslemetry.EnergySite.off_grid_vehicle_charging_reserve", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index c49e83803cd..b17b52903fa 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -41,7 +41,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.test_seat_heater_front_left" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.remote_seat_heater_request", + "tesla_fleet_api.teslemetry.Vehicle.remote_seat_heater_request", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -56,7 +56,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.test_steering_wheel_heater" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.remote_steering_wheel_heat_level_request", + "tesla_fleet_api.teslemetry.Vehicle.remote_steering_wheel_heat_level_request", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -71,7 +71,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.energy_site_operation_mode" with patch( - "homeassistant.components.teslemetry.EnergySpecific.operation", + "tesla_fleet_api.teslemetry.EnergySite.operation", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -89,7 +89,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.energy_site_allow_export" with patch( - "homeassistant.components.teslemetry.EnergySpecific.grid_import_export", + "tesla_fleet_api.teslemetry.EnergySite.grid_import_export", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index c3c2252ab89..213811f6ea0 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -31,9 +31,7 @@ async def test_sensors( freezer.move_to("2024-01-01 00:00:00+00:00") # Force the vehicle to use polling - with patch( - "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True - ): + with patch("tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True): entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) diff --git a/tests/components/teslemetry/test_services.py b/tests/components/teslemetry/test_services.py index a5b55f5dcc5..bcf5407999f 100644 --- a/tests/components/teslemetry/test_services.py +++ b/tests/components/teslemetry/test_services.py @@ -51,7 +51,7 @@ async def test_services( ).device_id with patch( - "homeassistant.components.teslemetry.VehicleSpecific.navigation_gps_request", + "tesla_fleet_api.teslemetry.Vehicle.navigation_gps_request", return_value=COMMAND_OK, ) as navigation_gps_request: await hass.services.async_call( @@ -66,7 +66,7 @@ async def test_services( navigation_gps_request.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_scheduled_charging", + "tesla_fleet_api.teslemetry.Vehicle.set_scheduled_charging", return_value=COMMAND_OK, ) as set_scheduled_charging: await hass.services.async_call( @@ -93,7 +93,7 @@ async def test_services( ) with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_scheduled_departure", + "tesla_fleet_api.teslemetry.Vehicle.set_scheduled_departure", return_value=COMMAND_OK, ) as set_scheduled_departure: await hass.services.async_call( @@ -138,7 +138,7 @@ async def test_services( ) with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_valet_mode", + "tesla_fleet_api.teslemetry.Vehicle.set_valet_mode", return_value=COMMAND_OK, ) as set_valet_mode: await hass.services.async_call( @@ -154,7 +154,7 @@ async def test_services( set_valet_mode.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.speed_limit_activate", + "tesla_fleet_api.teslemetry.Vehicle.speed_limit_activate", return_value=COMMAND_OK, ) as speed_limit_activate: await hass.services.async_call( @@ -170,7 +170,7 @@ async def test_services( speed_limit_activate.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.speed_limit_deactivate", + "tesla_fleet_api.teslemetry.Vehicle.speed_limit_deactivate", return_value=COMMAND_OK, ) as speed_limit_deactivate: await hass.services.async_call( @@ -186,7 +186,7 @@ async def test_services( speed_limit_deactivate.assert_called_once() with patch( - "homeassistant.components.teslemetry.EnergySpecific.time_of_use_settings", + "tesla_fleet_api.teslemetry.EnergySite.time_of_use_settings", return_value=COMMAND_OK, ) as set_time_of_use: await hass.services.async_call( @@ -202,7 +202,7 @@ async def test_services( with ( patch( - "homeassistant.components.teslemetry.EnergySpecific.time_of_use_settings", + "tesla_fleet_api.teslemetry.EnergySite.time_of_use_settings", return_value=COMMAND_ERROR, ) as set_time_of_use, pytest.raises(HomeAssistantError), diff --git a/tests/components/teslemetry/test_switch.py b/tests/components/teslemetry/test_switch.py index 17522f0ce2a..6b31a28db59 100644 --- a/tests/components/teslemetry/test_switch.py +++ b/tests/components/teslemetry/test_switch.py @@ -49,41 +49,41 @@ async def test_switch_alt( @pytest.mark.parametrize( ("name", "on", "off"), [ - ("test_charge", "VehicleSpecific.charge_start", "VehicleSpecific.charge_stop"), + ("test_charge", "Vehicle.charge_start", "Vehicle.charge_stop"), ( "test_auto_seat_climate_left", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", ), ( "test_auto_seat_climate_right", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", ), ( "test_auto_steering_wheel_heater", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", + "Vehicle.remote_auto_steering_wheel_heat_climate_request", + "Vehicle.remote_auto_steering_wheel_heat_climate_request", ), ( "test_defrost", - "VehicleSpecific.set_preconditioning_max", - "VehicleSpecific.set_preconditioning_max", + "Vehicle.set_preconditioning_max", + "Vehicle.set_preconditioning_max", ), ( "energy_site_storm_watch", - "EnergySpecific.storm_mode", - "EnergySpecific.storm_mode", + "EnergySite.storm_mode", + "EnergySite.storm_mode", ), ( "energy_site_allow_charging_from_grid", - "EnergySpecific.grid_import_export", - "EnergySpecific.grid_import_export", + "EnergySite.grid_import_export", + "EnergySite.grid_import_export", ), ( "test_sentry_mode", - "VehicleSpecific.set_sentry_mode", - "VehicleSpecific.set_sentry_mode", + "Vehicle.set_sentry_mode", + "Vehicle.set_sentry_mode", ), ], ) @@ -96,7 +96,7 @@ async def test_switch_services( entity_id = f"switch.{name}" with patch( - f"homeassistant.components.teslemetry.{on}", + f"tesla_fleet_api.teslemetry.{on}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -110,7 +110,7 @@ async def test_switch_services( call.assert_called_once() with patch( - f"homeassistant.components.teslemetry.{off}", + f"tesla_fleet_api.teslemetry.{off}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py index 0f26b162043..af6c9d847f1 100644 --- a/tests/components/teslemetry/test_update.py +++ b/tests/components/teslemetry/test_update.py @@ -61,7 +61,7 @@ async def test_update_services( entity_id = "update.test_update" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.schedule_software_update", + "tesla_fleet_api.teslemetry.Vehicle.schedule_software_update", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py index e0aba73af17..5fb844ff6b4 100644 --- a/tests/components/tessie/conftest.py +++ b/tests/components/tessie/conftest.py @@ -85,7 +85,7 @@ def mock_request(): def mock_live_status(): """Mock Tesla Fleet API EnergySpecific live_status method.""" with patch( - "homeassistant.components.tessie.EnergySpecific.live_status", + "tesla_fleet_api.tessie.EnergySite.live_status", side_effect=lambda: deepcopy(LIVE_STATUS), ) as mock_live_status: yield mock_live_status @@ -95,7 +95,7 @@ def mock_live_status(): def mock_site_info(): """Mock Tesla Fleet API EnergySpecific site_info method.""" with patch( - "homeassistant.components.tessie.EnergySpecific.site_info", + "tesla_fleet_api.tessie.EnergySite.site_info", side_effect=lambda: deepcopy(SITE_INFO), ) as mock_live_status: yield mock_live_status diff --git a/tests/components/tessie/test_number.py b/tests/components/tessie/test_number.py index 0fb13779183..69bbe1c9087 100644 --- a/tests/components/tessie/test_number.py +++ b/tests/components/tessie/test_number.py @@ -67,7 +67,7 @@ async def test_numbers( entity_id = "number.energy_site_backup_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.backup", + "tesla_fleet_api.tessie.EnergySite.backup", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( @@ -85,7 +85,7 @@ async def test_numbers( entity_id = "number.energy_site_off_grid_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.off_grid_vehicle_charging_reserve", + "tesla_fleet_api.tessie.EnergySite.off_grid_vehicle_charging_reserve", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py index c78923fbf5b..64380d363fc 100644 --- a/tests/components/tessie/test_select.py +++ b/tests/components/tessie/test_select.py @@ -52,7 +52,7 @@ async def test_select( # Test site operation mode entity_id = "select.energy_site_operation_mode" with patch( - "homeassistant.components.teslemetry.EnergySpecific.operation", + "tesla_fleet_api.tessie.EnergySite.operation", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( @@ -71,7 +71,7 @@ async def test_select( # Test site export mode entity_id = "select.energy_site_allow_export" with patch( - "homeassistant.components.teslemetry.EnergySpecific.grid_import_export", + "tesla_fleet_api.tessie.EnergySite.grid_import_export", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( @@ -129,7 +129,7 @@ async def test_errors(hass: HomeAssistant) -> None: # Test changing energy select with unknown error with ( patch( - "homeassistant.components.tessie.EnergySpecific.operation", + "tesla_fleet_api.tessie.EnergySite.operation", side_effect=UnsupportedVehicle, ) as mock_set, pytest.raises(HomeAssistantError) as error, diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index 690ad7d1ab4..f58468edfb7 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -61,13 +61,13 @@ async def test_switches( [ ( "energy_site_storm_watch", - "EnergySpecific.storm_mode", - "EnergySpecific.storm_mode", + "storm_mode", + "storm_mode", ), ( "energy_site_allow_charging_from_grid", - "EnergySpecific.grid_import_export", - "EnergySpecific.grid_import_export", + "grid_import_export", + "grid_import_export", ), ], ) @@ -80,7 +80,7 @@ async def test_switch_services( entity_id = f"switch.{name}" with patch( - f"homeassistant.components.teslemetry.{on}", + f"tesla_fleet_api.tessie.EnergySite.{on}", return_value=RESPONSE_OK, ) as call: await hass.services.async_call( @@ -94,7 +94,7 @@ async def test_switch_services( call.assert_called_once() with patch( - f"homeassistant.components.teslemetry.{off}", + f"tesla_fleet_api.tessie.EnergySite.{off}", return_value=RESPONSE_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tractive/test_device_tracker.py b/tests/components/tractive/test_device_tracker.py index ff78173ef7b..ff9c7ca88ef 100644 --- a/tests/components/tractive/test_device_tracker.py +++ b/tests/components/tractive/test_device_tracker.py @@ -59,3 +59,31 @@ async def test_source_type_phone( hass.states.get("device_tracker.test_pet_tracker").attributes["source_type"] is SourceType.BLUETOOTH ) + + +async def test_source_type_gps( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if the source type is GPS when the location sensor is KNOWN WIFI.""" + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_position_event( + mock_config_entry, + { + "tracker_id": "device_id_123", + "position": { + "latlong": [22.333, 44.555], + "accuracy": 99, + "sensor_used": "KNOWN_WIFI", + }, + }, + ) + mock_tractive_client.send_hardware_event(mock_config_entry) + await hass.async_block_till_done() + + assert ( + hass.states.get("device_tracker.test_pet_tracker").attributes["source_type"] + is SourceType.GPS + ) diff --git a/tests/components/usb/__init__.py b/tests/components/usb/__init__.py index 96d671d0958..6db0cea1ffe 100644 --- a/tests/components/usb/__init__.py +++ b/tests/components/usb/__init__.py @@ -1,44 +1,29 @@ """Tests for the USB Discovery integration.""" -from homeassistant.components.usb.models import USBDevice +from unittest.mock import patch -conbee_device = USBDevice( - device="/dev/cu.usbmodemDE24338801", - vid="1CF1", - pid="0030", - serial_number="DE2433880", - manufacturer="dresden elektronik ingenieurtechnik GmbH", - description="ConBee II", -) -slae_sh_device = USBDevice( - device="/dev/cu.usbserial-110", - vid="10C4", - pid="EA60", - serial_number="00_12_4B_00_22_98_88_7F", - manufacturer="Silicon Labs", - description="slae.sh cc2652rb stick - slaesh's iot stuff", -) -electro_lama_device = USBDevice( - device="/dev/cu.usbserial-110", - vid="1A86", - pid="7523", - serial_number=None, - manufacturer=None, - description="USB2.0-Serial", -) -skyconnect_macos_correct = USBDevice( - device="/dev/cu.SLAB_USBtoUART", - vid="10C4", - pid="EA60", - serial_number="9ab1da1ea4b3ed11956f4eaca7669f5d", - manufacturer="Nabu Casa", - description="SkyConnect v1.0", -) -skyconnect_macos_incorrect = USBDevice( - device="/dev/cu.usbserial-2110", - vid="10C4", - pid="EA60", - serial_number="9ab1da1ea4b3ed11956f4eaca7669f5d", - manufacturer="Nabu Casa", - description="SkyConnect v1.0", -) +from aiousbwatcher import InotifyNotAvailableError +import pytest + +from homeassistant.components.usb import async_request_scan as usb_async_request_scan +from homeassistant.core import HomeAssistant + + +@pytest.fixture(name="force_usb_polling_watcher") +def force_usb_polling_watcher(): + """Patch the USB integration to not use inotify and fall back to polling.""" + with patch( + "homeassistant.components.usb.AIOUSBWatcher.async_start", + side_effect=InotifyNotAvailableError, + ): + yield + + +def patch_scanned_serial_ports(**kwargs) -> None: + """Patch the USB integration's list of scanned serial ports.""" + return patch("homeassistant.components.usb.scan_serial_ports", **kwargs) + + +async def async_request_scan(hass: HomeAssistant) -> None: + """Request a USB scan.""" + return await usb_async_request_scan(hass) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 9730dba53d7..3a56e929b22 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -7,31 +7,40 @@ import os from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel -from aiousbwatcher import InotifyNotAvailableError import pytest from homeassistant.components import usb -from homeassistant.components.usb.utils import usb_device_from_port +from homeassistant.components.usb.models import USBDevice from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import conbee_device, slae_sh_device +from . import ( + force_usb_polling_watcher, # noqa: F401 + patch_scanned_serial_ports, +) from tests.common import async_fire_time_changed, import_and_test_deprecated_constant from tests.typing import WebSocketGenerator - -@pytest.fixture(name="aiousbwatcher_no_inotify") -def aiousbwatcher_no_inotify(): - """Patch AIOUSBWatcher to not use inotify.""" - with patch( - "homeassistant.components.usb.AIOUSBWatcher.async_start", - side_effect=InotifyNotAvailableError, - ): - yield +conbee_device = USBDevice( + device="/dev/cu.usbmodemDE24338801", + vid="1CF1", + pid="0030", + serial_number="DE2433880", + manufacturer="dresden elektronik ingenieurtechnik GmbH", + description="ConBee II", +) +slae_sh_device = USBDevice( + device="/dev/cu.usbserial-110", + vid="10C4", + pid="EA60", + serial_number="00_12_4B_00_22_98_88_7F", + manufacturer="Silicon Labs", + description="slae.sh cc2652rb stick - slaesh's iot stuff", +) async def test_aiousbwatcher_discovery( @@ -40,11 +49,11 @@ async def test_aiousbwatcher_discovery( """Test that aiousbwatcher can discover a device without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}, {"domain": "test2", "vid": "0FA0"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -63,7 +72,7 @@ async def test_aiousbwatcher_discovery( with ( patch("sys.platform", "linux"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch( "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher ), @@ -81,11 +90,11 @@ async def test_aiousbwatcher_discovery( await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 - mock_comports.append( - MagicMock( + mock_ports.append( + USBDevice( device=slae_sh_device.device, - vid=4000, - pid=4000, + vid="0FA0", + pid="0FA0", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -107,7 +116,7 @@ async def test_aiousbwatcher_discovery( await hass.async_block_till_done() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_polling_discovery( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -115,19 +124,19 @@ async def test_polling_discovery( new_usb = [{"domain": "test1", "vid": "3039"}] mock_comports_found_device = asyncio.Event() - def get_comports() -> list: - nonlocal mock_comports + def scan_serial_ports() -> list: + nonlocal mock_ports # Only "find" a device after a few invocations - if len(mock_comports.mock_calls) < 5: + if len(mock_ports.mock_calls) < 5: return [] mock_comports_found_device.set() return [ - MagicMock( + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -141,9 +150,7 @@ async def test_polling_discovery( timedelta(seconds=0.01), ), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch( - "homeassistant.components.usb.comports", side_effect=get_comports - ) as mock_comports, + patch_scanned_serial_ports(side_effect=scan_serial_ports) as mock_ports, patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -163,16 +170,16 @@ async def test_polling_discovery( await hass.async_block_till_done() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> None: """Test a device is removed by the aiousbwatcher before started.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -181,13 +188,13 @@ async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> N with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() - with patch("homeassistant.components.usb.comports", return_value=[]): + with patch_scanned_serial_ports(return_value=[]): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -197,18 +204,18 @@ async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> N await hass.async_block_till_done() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -217,7 +224,7 @@ async def test_discovered_by_websocket_scan( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -234,7 +241,7 @@ async def test_discovered_by_websocket_scan( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_limited_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -243,11 +250,11 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*2652*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -256,7 +263,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -273,7 +280,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_most_targeted_matcher_wins( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -283,11 +290,11 @@ async def test_most_targeted_matcher_wins( {"domain": "more", "vid": "3039", "pid": "3039", "description": "*2652*"}, ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -296,7 +303,7 @@ async def test_most_targeted_matcher_wins( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -313,7 +320,7 @@ async def test_most_targeted_matcher_wins( assert mock_config_flow.mock_calls[0][1][0] == "more" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_rejected_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -322,11 +329,11 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*not_it*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -335,7 +342,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -351,7 +358,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -365,11 +372,11 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( } ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -378,7 +385,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -395,7 +402,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -404,11 +411,11 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -417,7 +424,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -433,7 +440,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -447,11 +454,11 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( } ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=conbee_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, @@ -460,7 +467,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -477,7 +484,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -491,11 +498,11 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( } ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=conbee_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, @@ -504,7 +511,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -520,7 +527,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -529,11 +536,11 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=conbee_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=None, manufacturer=None, description=None, @@ -542,7 +549,7 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -558,18 +565,18 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_match_vid_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan only matching vid.""" new_usb = [{"domain": "test1", "vid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -578,7 +585,7 @@ async def test_discovered_by_websocket_scan_match_vid_only( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -595,18 +602,18 @@ async def test_discovered_by_websocket_scan_match_vid_only( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_match_vid_wrong_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan only matching vid but wrong pid.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -615,7 +622,7 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -631,15 +638,15 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_no_vid_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan with no vid or pid.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, vid=None, pid=None, @@ -651,7 +658,7 @@ async def test_discovered_by_websocket_no_vid_pid( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -667,18 +674,18 @@ async def test_discovered_by_websocket_no_vid_pid( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_non_matching_discovered_by_scanner_after_started( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a websocket scan that does not match.""" new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -687,7 +694,7 @@ async def test_non_matching_discovered_by_scanner_after_started( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -709,11 +716,11 @@ async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( """Test that aiousbwatcher on WSL failure results in fallback to scanning without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -722,7 +729,7 @@ async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -743,17 +750,17 @@ async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) - """Test a device is discovered since aiousbwatcher is now running.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ) ] - initial_mock_comports = [] + initial_ports = [] aiousbwatcher_callback = None def async_register_callback(callback): @@ -766,9 +773,7 @@ async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) - with ( patch("sys.platform", "linux"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch( - "homeassistant.components.usb.comports", return_value=initial_mock_comports - ), + patch_scanned_serial_ports(return_value=initial_ports), patch( "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher ), @@ -782,7 +787,7 @@ async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) - assert len(mock_config_flow.mock_calls) == 0 - initial_mock_comports.extend(mock_comports) + initial_ports.extend(mock_ports) aiousbwatcher_callback() await hass.async_block_till_done() @@ -874,18 +879,18 @@ def test_human_readable_device_name() -> None: assert "8A2A" in name -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_async_is_plugged_in( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test async_is_plugged_in.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -899,7 +904,7 @@ async def test_async_is_plugged_in( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -909,7 +914,7 @@ async def test_async_is_plugged_in( assert not usb.async_is_plugged_in(hass, matcher) with ( - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init"), ): ws_client = await hass_ws_client(hass) @@ -920,7 +925,7 @@ async def test_async_is_plugged_in( assert usb.async_is_plugged_in(hass, matcher) -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @pytest.mark.parametrize( "matcher", [ @@ -940,7 +945,7 @@ async def test_async_is_plugged_in_case_enforcement( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -952,7 +957,7 @@ async def test_async_is_plugged_in_case_enforcement( usb.async_is_plugged_in(hass, matcher) -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_web_socket_triggers_discovery_request_callbacks( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -961,7 +966,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( with ( patch("homeassistant.components.usb.async_get_usb", return_value=[]), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -987,7 +992,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( assert len(mock_callback.mock_calls) == 1 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -997,7 +1002,7 @@ async def test_initial_scan_callback( with ( patch("homeassistant.components.usb.async_get_usb", return_value=[]), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1023,7 +1028,7 @@ async def test_initial_scan_callback( cancel_2() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_cancel_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1032,7 +1037,7 @@ async def test_cancel_initial_scan_callback( with ( patch("homeassistant.components.usb.async_get_usb", return_value=[]), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1049,18 +1054,18 @@ async def test_cancel_initial_scan_callback( assert len(mock_callback.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_resolve_serial_by_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test the discovery data resolves to serial/by-id.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -1069,7 +1074,7 @@ async def test_resolve_serial_by_id( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch( "homeassistant.components.usb.get_serial_by_id", return_value="/dev/serial/by-id/bla", @@ -1091,73 +1096,73 @@ async def test_resolve_serial_by_id( assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/serial/by-id/bla" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @pytest.mark.parametrize( "ports", [ [ - MagicMock( + USBDevice( device="/dev/cu.usbserial-2120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.usbserial-1120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART2", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), ], [ - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART2", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.usbserial-1120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.usbserial-2120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, @@ -1177,7 +1182,7 @@ async def test_cp2102n_ordering_on_macos( with ( patch("sys.platform", "darwin"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=ports), + patch_scanned_serial_ports(return_value=ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1224,34 +1229,31 @@ def test_deprecated_constants( ) -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) async def test_register_port_event_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test the registration of a port event callback.""" - port1 = Mock( + port1 = USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ) - port2 = Mock( + port2 = USBDevice( device=conbee_device.device, - vid=12346, - pid=12346, + vid="303A", + pid="303A", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ) - port1_usb = usb_device_from_port(port1) - port2_usb = usb_device_from_port(port2) - ws_client = await hass_ws_client(hass) mock_callback1 = Mock() @@ -1259,7 +1261,7 @@ async def test_register_port_event_callback( # Start off with no ports with ( - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1270,13 +1272,13 @@ async def test_register_port_event_callback( assert mock_callback2.mock_calls == [] # Add two new ports - with patch("homeassistant.components.usb.comports", return_value=[port1, port2]): + with patch_scanned_serial_ports(return_value=[port1, port2]): await ws_client.send_json({"id": 1, "type": "usb/scan"}) response = await ws_client.receive_json() assert response["success"] - assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] - assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback1.mock_calls == [call({port1, port2}, set())] + assert mock_callback2.mock_calls == [call({port1, port2}, set())] # Cancel the second callback cancel2() @@ -1286,20 +1288,20 @@ async def test_register_port_event_callback( mock_callback2.reset_mock() # Remove port 2 - with patch("homeassistant.components.usb.comports", return_value=[port1]): + with patch_scanned_serial_ports(return_value=[port1]): await ws_client.send_json({"id": 2, "type": "usb/scan"}) response = await ws_client.receive_json() assert response["success"] await hass.async_block_till_done() - assert mock_callback1.mock_calls == [call(set(), {port2_usb})] + assert mock_callback1.mock_calls == [call(set(), {port2})] assert mock_callback2.mock_calls == [] # The second callback was unregistered mock_callback1.reset_mock() mock_callback2.reset_mock() # Keep port 2 removed - with patch("homeassistant.components.usb.comports", return_value=[port1]): + with patch_scanned_serial_ports(return_value=[port1]): await ws_client.send_json({"id": 3, "type": "usb/scan"}) response = await ws_client.receive_json() assert response["success"] @@ -1310,17 +1312,17 @@ async def test_register_port_event_callback( assert mock_callback2.mock_calls == [] # Unplug one and plug in the other - with patch("homeassistant.components.usb.comports", return_value=[port2]): + with patch_scanned_serial_ports(return_value=[port2]): await ws_client.send_json({"id": 4, "type": "usb/scan"}) response = await ws_client.receive_json() assert response["success"] await hass.async_block_till_done() - assert mock_callback1.mock_calls == [call({port2_usb}, {port1_usb})] + assert mock_callback1.mock_calls == [call({port2}, {port1})] assert mock_callback2.mock_calls == [] -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) async def test_register_port_event_callback_failure( hass: HomeAssistant, @@ -1329,27 +1331,24 @@ async def test_register_port_event_callback_failure( ) -> None: """Test port event callback failure handling.""" - port1 = Mock( + port1 = USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ) - port2 = Mock( + port2 = USBDevice( device=conbee_device.device, - vid=12346, - pid=12346, + vid="303A", + pid="303A", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ) - port1_usb = usb_device_from_port(port1) - port2_usb = usb_device_from_port(port2) - ws_client = await hass_ws_client(hass) mock_callback1 = Mock(side_effect=RuntimeError("Failure 1")) @@ -1357,7 +1356,7 @@ async def test_register_port_event_callback_failure( # Start off with no ports with ( - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1369,7 +1368,7 @@ async def test_register_port_event_callback_failure( # Add two new ports with ( - patch("homeassistant.components.usb.comports", return_value=[port1, port2]), + patch_scanned_serial_ports(return_value=[port1, port2]), caplog.at_level(logging.ERROR, logger="homeassistant.components.usb"), ): await ws_client.send_json({"id": 1, "type": "usb/scan"}) @@ -1378,8 +1377,8 @@ async def test_register_port_event_callback_failure( await hass.async_block_till_done() # Both were called even though they raised exceptions - assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] - assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback1.mock_calls == [call({port1, port2}, set())] + assert mock_callback2.mock_calls == [call({port1, port2}, set())] assert caplog.text.count("Error in USB port event callback") == 2 assert "Failure 1" in caplog.text diff --git a/tests/components/vodafone_station/test_button.py b/tests/components/vodafone_station/test_button.py index d5f377d3f6f..ade5eb78965 100644 --- a/tests/components/vodafone_station/test_button.py +++ b/tests/components/vodafone_station/test_button.py @@ -2,11 +2,20 @@ from unittest.mock import AsyncMock, patch +from aiovodafone.exceptions import ( + AlreadyLogged, + CannotAuthenticate, + CannotConnect, + GenericLoginError, +) +import pytest from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.vodafone_station.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -46,3 +55,39 @@ async def test_pressing_button( blocking=True, ) mock_vodafone_station_router.restart_router.assert_called_once() + + +@pytest.mark.parametrize( + ("side_effect", "key", "error"), + [ + (CannotConnect, "cannot_execute_action", "CannotConnect()"), + (AlreadyLogged, "cannot_execute_action", "AlreadyLogged()"), + (GenericLoginError, "cannot_execute_action", "GenericLoginError()"), + (CannotAuthenticate, "cannot_authenticate", "CannotAuthenticate()"), + ], +) +async def test_button_fails( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + key: str, + error: str, +) -> None: + """Test button action fails.""" + + await setup_integration(hass, mock_config_entry) + + mock_vodafone_station_router.restart_router.side_effect = side_effect + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.vodafone_station_m123456789_restart"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == key + assert exc_info.value.translation_placeholders == {"error": error} diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 68f8247bdf9..0648987eb27 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -228,3 +228,75 @@ async def test_options_flow( assert result["data"] == { CONF_CONSIDER_HOME: 37, } + + +async def test_reconfigure_successful( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the host can be reconfigured.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # original entry + assert mock_config_entry.data["host"] == "fake_host" + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "192.168.100.60", + "password": "fake_password", + "username": "fake_username", + }, + ) + + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data["host"] == "192.168.100.60" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (AlreadyLogged, "already_logged"), + (ConnectionResetError, "unknown"), + ], +) +async def test_reconfigure_fails( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test that the host can be reconfigured.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_vodafone_station_router.login.side_effect = side_effect + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "192.168.100.60", + "password": "fake_password", + "username": "fake_username", + }, + ) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + assert reconfigure_result["errors"] == {"base": error} diff --git a/tests/components/vodafone_station/test_coordinator.py b/tests/components/vodafone_station/test_coordinator.py index 1a9470245c7..5f75b538803 100644 --- a/tests/components/vodafone_station/test_coordinator.py +++ b/tests/components/vodafone_station/test_coordinator.py @@ -40,8 +40,7 @@ async def test_coordinator_device_cleanup( device_tracker = f"device_tracker.{DEVICE_1_HOST}" - state = hass.states.get(device_tracker) - assert state is not None + assert hass.states.get(device_tracker) mock_vodafone_station_router.get_devices_data.return_value = { DEVICE_2_MAC: VodafoneStationDevice( @@ -59,10 +58,10 @@ async def test_coordinator_device_cleanup( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(device_tracker) - assert state is None + assert hass.states.get(device_tracker) is None assert f"Skipping entity {DEVICE_2_HOST}" in caplog.text - device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_1_MAC)}) - assert device is None + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_1_MAC)}) is None + ) assert f"Removing device: {DEVICE_1_HOST}" in caplog.text diff --git a/tests/components/vodafone_station/test_device_tracker.py b/tests/components/vodafone_station/test_device_tracker.py index e172fa76de5..a94f4ad05c4 100644 --- a/tests/components/vodafone_station/test_device_tracker.py +++ b/tests/components/vodafone_station/test_device_tracker.py @@ -47,8 +47,7 @@ async def test_consider_home( device_tracker = f"device_tracker.{DEVICE_1_HOST}" - state = hass.states.get(device_tracker) - assert state + assert (state := hass.states.get(device_tracker)) assert state.state == STATE_HOME mock_vodafone_station_router.get_devices_data.return_value[ @@ -59,6 +58,5 @@ async def test_consider_home( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(device_tracker) - assert state + assert (state := hass.states.get(device_tracker)) assert state.state == STATE_NOT_HOME diff --git a/tests/components/vodafone_station/test_sensor.py b/tests/components/vodafone_station/test_sensor.py index ddf97824c75..5f27b67e3dd 100644 --- a/tests/components/vodafone_station/test_sensor.py +++ b/tests/components/vodafone_station/test_sensor.py @@ -55,8 +55,7 @@ async def test_active_connection_type( active_connection_entity = "sensor.vodafone_station_m123456789_active_connection" - state = hass.states.get(active_connection_entity) - assert state + assert (state := hass.states.get(active_connection_entity)) assert state.state == STATE_UNKNOWN mock_vodafone_station_router.get_sensor_data.return_value[connection_type] = ( @@ -67,8 +66,7 @@ async def test_active_connection_type( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(active_connection_entity) - assert state + assert (state := hass.states.get(active_connection_entity)) assert state.state == LINE_TYPES[index] @@ -85,8 +83,7 @@ async def test_uptime( uptime = "2024-11-19T20:19:00+00:00" uptime_entity = "sensor.vodafone_station_m123456789_uptime" - state = hass.states.get(uptime_entity) - assert state + assert (state := hass.states.get(uptime_entity)) assert state.state == uptime mock_vodafone_station_router.get_sensor_data.return_value["sys_uptime"] = "12:17:23" @@ -95,8 +92,7 @@ async def test_uptime( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(uptime_entity) - assert state + assert (state := hass.states.get(uptime_entity)) assert state.state == uptime @@ -124,6 +120,5 @@ async def test_coordinator_client_connector_error( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.vodafone_station_m123456789_uptime") - assert state + assert (state := hass.states.get("sensor.vodafone_station_m123456789_uptime")) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/watergate/snapshots/test_event.ambr b/tests/components/watergate/snapshots/test_event.ambr new file mode 100644 index 00000000000..97f453697ca --- /dev/null +++ b/tests/components/watergate/snapshots/test_event.ambr @@ -0,0 +1,111 @@ +# serializer version: 1 +# name: test_event[event.sonic_duration_auto_shut_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'duration_threshold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sonic_duration_auto_shut_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Duration auto shut-off', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_shut_off_duration', + 'unique_id': 'a63182948ce2896a.auto_shut_off_duration', + 'unit_of_measurement': None, + }) +# --- +# name: test_event[event.sonic_duration_auto_shut_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'duration_threshold', + ]), + 'friendly_name': 'Sonic Duration auto shut-off', + }), + 'context': , + 'entity_id': 'event.sonic_duration_auto_shut_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_event[event.sonic_volume_auto_shut_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'volume_threshold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sonic_volume_auto_shut_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume auto shut-off', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_shut_off_volume', + 'unique_id': 'a63182948ce2896a.auto_shut_off_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_event[event.sonic_volume_auto_shut_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'volume_threshold', + ]), + 'friendly_name': 'Sonic Volume auto shut-off', + }), + 'context': , + 'entity_id': 'event.sonic_volume_auto_shut_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/watergate/test_event.py b/tests/components/watergate/test_event.py new file mode 100644 index 00000000000..6997c3f1fdf --- /dev/null +++ b/tests/components/watergate/test_event.py @@ -0,0 +1,84 @@ +"""Tests for the Watergate event entity platform.""" + +from collections.abc import Generator + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.typing import StateType + +from . import init_integration +from .const import MOCK_WEBHOOK_ID + +from tests.common import AsyncMock, MockConfigEntry, patch, snapshot_platform +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_event( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test states of the sensor.""" + freezer.move_to("2021-01-09 12:00:00+00:00") + with patch("homeassistant.components.watergate.PLATFORMS", [Platform.EVENT]): + await init_integration(hass, mock_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "event_type"), + [ + ("sonic_volume_auto_shut_off", "volume_threshold"), + ("sonic_duration_auto_shut_off", "duration_threshold"), + ], +) +async def test_auto_shut_off_webhook( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], + entity_id: str, + event_type: str, +) -> None: + """Test if water flow webhook is handled correctly.""" + await init_integration(hass, mock_entry) + + def assert_state(entity_id: str, expected_state: str): + state = hass.states.get(f"event.{entity_id}") + assert state.state == str(expected_state) + + assert_state(entity_id, "unknown") + + telemetry_change_data = { + "type": "auto-shut-off-report", + "data": { + "type": event_type, + "volume": 1500, + "duration": 30, + "timestamp": 1730148016, + }, + } + client = await hass_client_no_auth() + await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=telemetry_change_data) + + await hass.async_block_till_done() + + def assert_extra_state( + entity_id: str, attribute: str, expected_attribute: StateType + ): + attributes = hass.states.get(f"event.{entity_id}").attributes + assert attributes.get(attribute) == expected_attribute + + assert_extra_state(entity_id, "event_type", event_type) + assert_extra_state(entity_id, "volume", 1500) + assert_extra_state(entity_id, "duration", 30) diff --git a/tests/components/watergate/test_sensor.py b/tests/components/watergate/test_sensor.py index 78e375857ed..0bf883a1955 100644 --- a/tests/components/watergate/test_sensor.py +++ b/tests/components/watergate/test_sensor.py @@ -1,4 +1,4 @@ -"""Tests for the Watergate valve platform.""" +"""Tests for the Watergate sensor platform.""" from collections.abc import Generator diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py index eb887edb1a1..9204e6eadab 100644 --- a/tests/components/webdav/test_config_flow.py +++ b/tests/components/webdav/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import UnauthorizedError +from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError import pytest from homeassistant import config_entries @@ -86,6 +86,7 @@ async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: ("exception", "expected_error"), [ (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (MethodNotSupportedError("check", "https://webdav.demo"), "invalid_method"), (Exception("Unexpected error"), "unknown"), ], ) diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index 15ec1b15ee5..20fe5024962 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -17,10 +17,12 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture -def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: +async def mock_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Create http client for webhooks.""" - hass.loop.run_until_complete(async_setup_component(hass, "webhook", {})) - return hass.loop.run_until_complete(hass_client()) + await async_setup_component(hass, "webhook", {}) + return await hass_client() async def test_unregistering_webhook(hass: HomeAssistant, mock_client) -> None: diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c0114cde42b..f03673048c0 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -106,9 +106,8 @@ async def test_fire_event( hass.bus.async_listen_once("event_type_test", event_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "fire_event", "event_type": "event_type_test", "event_data": {"hello": "world"}, @@ -116,7 +115,6 @@ async def test_fire_event( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -137,16 +135,14 @@ async def test_fire_event_without_data( hass.bus.async_listen_once("event_type_test", event_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "fire_event", "event_type": "event_type_test", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -162,9 +158,8 @@ async def test_call_service( """Test call service command.""" calls = async_mock_service(hass, "domain_test", "test_service") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -173,7 +168,6 @@ async def test_call_service( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -191,9 +185,8 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N hass.services.async_register( "domain_test", "test_service_with_no_response", lambda x: None ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 8, "type": "call_service", "domain": "domain_test", "service": "test_service_with_no_response", @@ -203,7 +196,6 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N ) msg = await websocket_client.receive_json() - assert msg["id"] == 8 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "service_validation_error" @@ -225,9 +217,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = {"foo": "bar"} - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 4, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -237,7 +228,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 4 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["response"] == {"foo": "bar"} @@ -256,9 +246,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -267,7 +256,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -286,9 +274,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "homeassistant", "service": "test_service", @@ -296,7 +283,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -315,9 +301,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "homeassistant", "service": "restart", @@ -325,7 +310,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -346,9 +330,8 @@ async def test_call_service_target( """Test call service command with target.""" calls = async_mock_service(hass, "domain_test", "test_service") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -361,7 +344,6 @@ async def test_call_service_target( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -382,9 +364,8 @@ async def test_call_service_target_template( hass: HomeAssistant, websocket_client ) -> None: """Test call service command with target does not allow template.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -396,7 +377,6 @@ async def test_call_service_target_template( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT @@ -406,9 +386,8 @@ async def test_call_service_not_found( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test call service command.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -417,7 +396,6 @@ async def test_call_service_not_found( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_NOT_FOUND @@ -440,9 +418,8 @@ async def test_call_service_child_not_found( hass.services.async_register("domain_test", "test_service", serv_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -451,7 +428,6 @@ async def test_call_service_child_not_found( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_HOME_ASSISTANT_ERROR @@ -492,9 +468,8 @@ async def test_call_service_schema_validation_error( schema=service_schema, ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -502,14 +477,12 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -517,14 +490,12 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -532,7 +503,6 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT @@ -573,9 +543,8 @@ async def test_call_service_error( hass.services.async_register("domain_test", "unknown_error", unknown_error_call) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "ha_error", @@ -583,7 +552,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "home_assistant_error" @@ -592,9 +560,8 @@ async def test_call_service_error( assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "domain_test", "service": "service_error", @@ -602,7 +569,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "service_validation_error" @@ -611,9 +577,8 @@ async def test_call_service_error( assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "domain_test", "service": "unknown_error", @@ -621,7 +586,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "unknown_error" @@ -634,12 +598,12 @@ async def test_subscribe_unsubscribe_events( """Test subscribe/unsubscribe events command.""" init_count = sum(hass.bus.async_listeners().values()) - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "test_event"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "test_event"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -653,7 +617,7 @@ async def test_subscribe_unsubscribe_events( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] @@ -661,12 +625,11 @@ async def test_subscribe_unsubscribe_events( assert event["data"] == {"hello": "world"} assert event["origin"] == "LOCAL" - await websocket_client.send_json( - {"id": 6, "type": "unsubscribe_events", "subscription": 5} + await websocket_client.send_json_auto_id( + {"type": "unsubscribe_events", "subscription": subscription} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -681,10 +644,9 @@ async def test_get_states( hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bye", "universe") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -711,10 +673,9 @@ async def test_get_config( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test get_config command.""" - await websocket_client.send_json({"id": 5, "type": "get_config"}) + await websocket_client.send_json_auto_id({"type": "get_config"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -737,10 +698,9 @@ async def test_get_config( async def test_ping(websocket_client: MockHAClientWebSocket) -> None: """Test get_panels command.""" - await websocket_client.send_json({"id": 5, "type": "ping"}) + await websocket_client.send_json_auto_id({"type": "ping"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == "pong" @@ -792,8 +752,8 @@ async def test_subscribe_requires_admin( ) -> None: """Test subscribing events without being admin.""" hass_admin_user.groups = [] - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "test_event"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "test_event"} ) msg = await websocket_client.receive_json() @@ -809,10 +769,9 @@ async def test_states_filters_visible( hass_admin_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) hass.states.async_set("test.entity", "hello") hass.states.async_set("test.not_visible_entity", "invisible") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -828,13 +787,12 @@ async def test_get_states_not_allows_nan( hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) hass.states.async_set("greeting.bye", "universe") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) bad = dict(hass.states.get("greeting.bad").as_dict()) bad["attributes"] = dict(bad["attributes"]) bad["attributes"]["hello"] = None msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [ @@ -852,22 +810,21 @@ async def test_subscribe_unsubscribe_events_whitelist( """Test subscribe/unsubscribe events on whitelist.""" hass_admin_user.groups = [] - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "not-in-whitelist"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "not-in-whitelist"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "unauthorized" - await websocket_client.send_json( - {"id": 6, "type": "subscribe_events", "event_type": "themes_updated"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "themes_updated"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 + themes_updated_subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -876,7 +833,7 @@ async def test_subscribe_unsubscribe_events_whitelist( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 6 + assert msg["id"] == themes_updated_subscription assert msg["type"] == "event" event = msg["event"] assert event["event_type"] == "themes_updated" @@ -892,12 +849,12 @@ async def test_subscribe_unsubscribe_events_state_changed( hass_admin_user.groups = [] hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) - await websocket_client.send_json( - {"id": 7, "type": "subscribe_events", "event_type": "state_changed"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "state_changed"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -905,7 +862,7 @@ async def test_subscribe_unsubscribe_events_state_changed( hass.states.async_set("light.permitted", "on") msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"]["event_type"] == "state_changed" assert msg["event"]["data"]["entity_id"] == "light.permitted" @@ -949,15 +906,15 @@ async def test_subscribe_entities_with_unserializable_state( } ) - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -971,7 +928,7 @@ async def test_subscribe_entities_with_unserializable_state( } hass.states.async_set("light.permitted", "on", {"effect": "help"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -988,7 +945,7 @@ async def test_subscribe_entities_with_unserializable_state( } hass.states.async_set("light.cannot_serialize", "on", {"effect": "help"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" # Order does not matter msg["event"]["c"]["light.cannot_serialize"]["-"]["a"] = set( @@ -1022,7 +979,7 @@ async def test_subscribe_entities_with_unserializable_state( {"color": "red", "cannot_serialize": CannotSerializeMe()}, ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "result" assert msg["error"] == { "code": "unknown_error", @@ -1052,15 +1009,15 @@ async def test_subscribe_unsubscribe_entities( hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) assert not hass_admin_user.is_admin - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert isinstance(msg["event"]["a"]["light.permitted"]["c"], str) assert msg["event"] == { @@ -1083,7 +1040,7 @@ async def test_subscribe_unsubscribe_entities( hass.states.async_set("light.permitted", "on", {"effect": "help", "color": "blue"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1115,7 +1072,7 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1148,7 +1105,7 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1180,12 +1137,12 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == {"r": ["light.permitted"]} msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -1219,17 +1176,17 @@ async def test_subscribe_unsubscribe_entities_specific_entities( } ) - await websocket_client.send_json( - {"id": 7, "type": "subscribe_entities", "entity_ids": ["light.permitted"]} + await websocket_client.send_json_auto_id( + {"type": "subscribe_entities", "entity_ids": ["light.permitted"]} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert isinstance(msg["event"]["a"]["light.permitted"]["c"], str) assert msg["event"] == { @@ -1247,7 +1204,7 @@ async def test_subscribe_unsubscribe_entities_specific_entities( hass.states.async_set("light.permitted", "on", {"color": "blue"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1271,17 +1228,17 @@ async def test_subscribe_unsubscribe_entities_with_filter( """Test subscribe/unsubscribe entities with an entity filter.""" hass.states.async_set("switch.not_included", "off") hass.states.async_set("light.include", "off") - await websocket_client.send_json( - {"id": 7, "type": "subscribe_entities", "include": {"domains": ["light"]}} + await websocket_client.send_json_auto_id( + {"type": "subscribe_entities", "include": {"domains": ["light"]}} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -1296,7 +1253,7 @@ async def test_subscribe_unsubscribe_entities_with_filter( hass.states.async_set("switch.not_included", "on") hass.states.async_set("light.include", "on") msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1317,21 +1274,20 @@ async def test_render_template_renders_template( """Test simple template is rendered and updated.""" hass.states.async_set("light.test", "on") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": "State is: {{ states('light.test') }}", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1346,7 +1302,7 @@ async def test_render_template_renders_template( hass.states.async_set("light.test", "off") msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1364,9 +1320,8 @@ async def test_render_template_with_timeout_and_variables( hass: HomeAssistant, websocket_client ) -> None: """Test a template with a timeout and variables renders without error.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "timeout": 10, "variables": {"test": {"value": "hello"}}, @@ -1375,12 +1330,12 @@ async def test_render_template_with_timeout_and_variables( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1400,21 +1355,20 @@ async def test_render_template_manual_entity_ids_no_longer_needed( """Test that updates to specified entity ids cause a template rerender.""" hass.states.async_set("light.test", "on") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": "State is: {{ states('light.test') }}", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1429,7 +1383,7 @@ async def test_render_template_manual_entity_ids_no_longer_needed( hass.states.async_set("light.test", "off") msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1523,9 +1477,8 @@ async def test_render_template_with_error( ) -> None: """Test a template with an error.""" caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "report_errors": True, @@ -1534,7 +1487,6 @@ async def test_render_template_with_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1596,9 +1548,8 @@ async def test_render_template_with_timeout_and_error( ) -> None: """Test a template with an error with a timeout.""" caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1608,7 +1559,6 @@ async def test_render_template_with_timeout_and_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1666,9 +1616,8 @@ async def test_render_template_strict_with_timeout_and_error( In this test report_errors is enabled. """ caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1679,7 +1628,6 @@ async def test_render_template_strict_with_timeout_and_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1729,9 +1677,8 @@ async def test_render_template_strict_with_timeout_and_error_2( In this test report_errors is disabled. """ caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1741,7 +1688,6 @@ async def test_render_template_strict_with_timeout_and_error_2( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1815,9 +1761,8 @@ async def test_render_template_error_in_template_code( In this test report_errors is enabled. """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "report_errors": True, @@ -1826,7 +1771,6 @@ async def test_render_template_error_in_template_code( for expected_event in expected_events_1: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1834,7 +1778,6 @@ async def test_render_template_error_in_template_code( for expected_event in expected_events_2: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1882,13 +1825,12 @@ async def test_render_template_error_in_template_code_2( In this test report_errors is disabled. """ - await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template} + await websocket_client.send_json_auto_id( + {"type": "render_template", "template": template} ) for expected_event in expected_events_1: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1896,7 +1838,6 @@ async def test_render_template_error_in_template_code_2( for expected_event in expected_events_2: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1924,9 +1865,8 @@ async def test_render_template_with_delayed_error( {% endif %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template_str, "report_errors": True, @@ -1935,7 +1875,7 @@ async def test_render_template_with_delayed_error( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -1943,7 +1883,7 @@ async def test_render_template_with_delayed_error( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1957,13 +1897,13 @@ async def test_render_template_with_delayed_error( } msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event["error"] == "'None' has no attribute 'state'" msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1994,9 +1934,8 @@ async def test_render_template_with_delayed_error_2( {% endif %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template_str, "report_errors": False, @@ -2005,7 +1944,7 @@ async def test_render_template_with_delayed_error_2( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2013,7 +1952,7 @@ async def test_render_template_with_delayed_error_2( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -2044,9 +1983,8 @@ async def test_render_template_with_timeout( {%- endfor %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "timeout": 0.000001, "template": slow_template_str, @@ -2054,7 +1992,6 @@ async def test_render_template_with_timeout( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR @@ -2066,12 +2003,11 @@ async def test_render_template_returns_with_match_all( hass: HomeAssistant, websocket_client ) -> None: """Test that a template that would match with all entities still return success.""" - await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": "State is: {{ 42 }}"} + await websocket_client.send_json_auto_id( + {"type": "render_template", "template": "State is: {{ 42 }}"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2083,10 +2019,9 @@ async def test_manifest_list( http = await async_get_integration(hass, "http") websocket_api = await async_get_integration(hass, "websocket_api") - await websocket_client.send_json({"id": 5, "type": "manifest/list"}) + await websocket_client.send_json_auto_id({"type": "manifest/list"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert sorted(msg["result"], key=lambda manifest: manifest["domain"]) == [ @@ -2101,13 +2036,12 @@ async def test_manifest_list_specific_integrations( """Test loading manifests for specific integrations.""" websocket_api = await async_get_integration(hass, "websocket_api") - await websocket_client.send_json( - {"id": 5, "type": "manifest/list", "integrations": ["hue", "websocket_api"]} + await websocket_client.send_json_auto_id( + {"type": "manifest/list", "integrations": ["hue", "websocket_api"]} ) hue = await async_get_integration(hass, "hue") msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert sorted(msg["result"], key=lambda manifest: manifest["domain"]) == [ @@ -2122,23 +2056,21 @@ async def test_manifest_get( """Test getting a manifest.""" hue = await async_get_integration(hass, "hue") - await websocket_client.send_json( - {"id": 6, "type": "manifest/get", "integration": "hue"} + await websocket_client.send_json_auto_id( + {"type": "manifest/get", "integration": "hue"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == hue.manifest # Non existing - await websocket_client.send_json( - {"id": 7, "type": "manifest/get", "integration": "non_existing"} + await websocket_client.send_json_auto_id( + {"type": "manifest/get", "integration": "non_existing"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "not_found" @@ -2157,10 +2089,9 @@ async def test_entity_source_admin( ) # Fetch all - await websocket_client.send_json({"id": 6, "type": "entity/source"}) + await websocket_client.send_json_auto_id({"type": "entity/source"}) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { @@ -2175,10 +2106,9 @@ async def test_entity_source_admin( ) # Fetch all - await websocket_client.send_json({"id": 10, "type": "entity/source"}) + await websocket_client.send_json_auto_id({"type": "entity/source"}) msg = await websocket_client.receive_json() - assert msg["id"] == 10 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { @@ -2192,9 +2122,8 @@ async def test_subscribe_trigger( """Test subscribing to a trigger.""" init_count = sum(hass.bus.async_listeners().values()) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "subscribe_trigger", "trigger": {"platform": "event", "event_type": "test_event"}, "variables": {"hello": "world"}, @@ -2202,7 +2131,6 @@ async def test_subscribe_trigger( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2218,7 +2146,6 @@ async def test_subscribe_trigger( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == "event" assert msg["event"]["context"]["id"] == context.id assert msg["event"]["variables"]["trigger"]["platform"] == "event" @@ -2229,12 +2156,11 @@ async def test_subscribe_trigger( assert event["data"] == {"hello": "world"} assert event["origin"] == "LOCAL" - await websocket_client.send_json( - {"id": 6, "type": "unsubscribe_events", "subscription": 5} + await websocket_client.send_json_auto_id( + {"type": "unsubscribe_events", "subscription": msg["id"]} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2248,9 +2174,8 @@ async def test_test_condition( """Test testing a condition.""" hass.states.async_set("hello.world", "paulus") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "test_condition", "condition": { "condition": "state", @@ -2262,14 +2187,12 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is True - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "test_condition", "condition": { "condition": "template", @@ -2280,14 +2203,12 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is True - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "test_condition", "condition": { "condition": "template", @@ -2298,7 +2219,6 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is False @@ -2312,9 +2232,8 @@ async def test_execute_script( hass, "domain_test", "test_service", response={"hello": "world"} ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "execute_script", "sequence": [ { @@ -2328,14 +2247,12 @@ async def test_execute_script( ) msg_no_var = await websocket_client.receive_json() - assert msg_no_var["id"] == 5 assert msg_no_var["type"] == const.TYPE_RESULT assert msg_no_var["success"] assert msg_no_var["result"]["response"] == {"hello": "world"} - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "execute_script", "sequence": { "service": "domain_test.test_service", @@ -2346,7 +2263,6 @@ async def test_execute_script( ) msg_var = await websocket_client.receive_json() - assert msg_var["id"] == 6 assert msg_var["type"] == const.TYPE_RESULT assert msg_var["success"] @@ -2403,9 +2319,8 @@ async def test_execute_script_err_localization( hass, "domain_test", "test_service", raise_exception=raise_exception ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "execute_script", "sequence": [ { @@ -2418,7 +2333,6 @@ async def test_execute_script_err_localization( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == err_code @@ -2522,12 +2436,12 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe bootstrap_integrations.""" - await websocket_client.send_json( - {"id": 7, "type": "subscribe_bootstrap_integrations"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_bootstrap_integrations"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2535,7 +2449,7 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, message) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == message @@ -2553,10 +2467,9 @@ async def test_integration_setup_info( "isy994": 12.8, }, ): - await websocket_client.send_json({"id": 7, "type": "integration/setup_info"}) + await websocket_client.send_json_auto_id({"type": "integration/setup_info"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [ @@ -2855,12 +2768,7 @@ async def test_integration_descriptions( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - await ws_client.send_json( - { - "id": 1, - "type": "integration/descriptions", - } - ) + await ws_client.send_json_auto_id({"type": "integration/descriptions"}) response = await ws_client.receive_json() assert response["success"] @@ -2884,31 +2792,31 @@ async def test_subscribe_entities_chained_state_change( async_track_state_change_event(hass, ["light.permitted"], auto_off_listener) - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == {"a": {}} hass.states.async_set("light.permitted", "on") data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": {"light.permitted": {"a": {}, "c": ANY, "lc": ANY, "s": "on"}} } data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": {"light.permitted": {"+": {"c": ANY, "lc": ANY, "s": "off"}}} diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 03e30c11ee9..075f5fa9c0a 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -16,6 +16,7 @@ from homeassistant.components.websocket_api import ( ) from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed @@ -241,7 +242,7 @@ async def test_pending_msg_peak( instance: http.WebSocketHandler = cast(http.WebSocketHandler, setup_instance) # Fill the queue past the allowed peak - for _ in range(10): + for _ in range(20): instance._send_message({"overload": "message"}) async_fire_time_changed( @@ -251,7 +252,7 @@ async def test_pending_msg_peak( msg = await websocket_client.receive() assert msg.type is WSMsgType.CLOSE assert "Client unable to keep up with pending messages" in caplog.text - assert "Stayed over 5 for 5 seconds" in caplog.text + assert "Stayed over 5 for 10 seconds" in caplog.text assert "overload" in caplog.text @@ -523,3 +524,36 @@ async def test_binary_message( assert "Received binary message for non-existing handler 0" in caplog.text assert "Received binary message for non-existing handler 3" in caplog.text assert "Received binary message for non-existing handler 10" in caplog.text + + +async def test_enable_disable_debug_logging( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enabling and disabling debug logging.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.websocket_api": "DEBUG"}, + blocking=True, + ) + await hass.async_block_till_done() + await websocket_client.send_json({"id": 1, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":1,"type":"pong"}\'' in caplog.text + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.websocket_api": "WARNING"}, + blocking=True, + ) + await hass.async_block_till_done() + await websocket_client.send_json({"id": 2, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 2 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":2,"type":"pong"}\'' not in caplog.text diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 93881d3735a..5d063f02924 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -141,31 +141,21 @@ def fixture_mock_aircon_api_instances(mock_aircon1_api, mock_aircon2_api): yield mock_aircon_api -def side_effect_function(*args, **kwargs): - """Return correct value for attribute.""" - if args[0] == "Cavity_TimeStatusEstTimeRemaining": - return 3540 - if args[0] == "Cavity_OpStatusDoorOpen": - return "0" - if args[0] == "WashCavity_OpStatusBulkDispense1Level": - return "3" - - return None - - -def get_sensor_mock(said): +def get_sensor_mock(said: str, data_model: str): """Get a mock of a sensor.""" mock_sensor = mock.Mock(said=said) mock_sensor.name = f"WasherDryer {said}" mock_sensor.register_attr_callback = MagicMock() - mock_sensor.appliance_info.data_model = "washer_dryer_model" + mock_sensor.appliance_info.data_model = data_model mock_sensor.appliance_info.category = "washer_dryer" mock_sensor.appliance_info.model_number = "12345" mock_sensor.get_online.return_value = True mock_sensor.get_machine_state.return_value = ( whirlpool.washerdryer.MachineState.Standby ) - mock_sensor.get_attribute.side_effect = side_effect_function + mock_sensor.get_door_open.return_value = False + mock_sensor.get_dispense_1_level.return_value = 3 + mock_sensor.get_time_remaining.return_value = 3540 mock_sensor.get_cycle_status_filling.return_value = False mock_sensor.get_cycle_status_rinsing.return_value = False mock_sensor.get_cycle_status_sensing.return_value = False @@ -179,13 +169,13 @@ def get_sensor_mock(said): @pytest.fixture(name="mock_sensor1_api", autouse=False) def fixture_mock_sensor1_api(): """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID3) + return get_sensor_mock(MOCK_SAID3, "washer") @pytest.fixture(name="mock_sensor2_api", autouse=False) def fixture_mock_sensor2_api(): """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID4) + return get_sensor_mock(MOCK_SAID4, "dryer") @pytest.fixture(name="mock_sensor_api_instances", autouse=False) diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index 7ffae8bc808..7294e914f51 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -19,12 +19,12 @@ 'washer_dryers': dict({ 'WasherDryer said3': dict({ 'category': 'washer_dryer', - 'data_model': 'washer_dryer_model', + 'data_model': 'washer', 'model_number': '12345', }), 'WasherDryer said4': dict({ 'category': 'washer_dryer', - 'data_model': 'washer_dryer_model', + 'data_model': 'dryer', 'model_number': '12345', }), }), diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 95fca331707..43a5421391b 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -30,20 +30,6 @@ async def update_sensor_state( return hass.states.get(entity_id) -def side_effect_function_open_door(*args, **kwargs): - """Return correct value for attribute.""" - if args[0] == "Cavity_TimeStatusEstTimeRemaining": - return 3540 - - if args[0] == "Cavity_OpStatusDoorOpen": - return "1" - - if args[0] == "WashCavity_OpStatusBulkDispense1Level": - return "3" - - return None - - async def test_dryer_sensor_values( hass: HomeAssistant, mock_sensor2_api: MagicMock, entity_registry: er.EntityRegistry ) -> None: @@ -66,7 +52,7 @@ async def test_dryer_sensor_values( await init_integration(hass) - entity_id = f"sensor.washerdryer_{MOCK_SAID4}_state" + entity_id = f"sensor.washerdryer_{MOCK_SAID4}_none" mock_instance = mock_sensor2_api entry = entity_registry.async_get(entity_id) assert entry @@ -130,7 +116,7 @@ async def test_washer_sensor_values( ) await hass.async_block_till_done() - entity_id = f"sensor.washerdryer_{MOCK_SAID3}_state" + entity_id = f"sensor.washerdryer_{MOCK_SAID3}_none" mock_instance = mock_sensor1_api entry = entity_registry.async_get(entity_id) assert entry @@ -258,7 +244,7 @@ async def test_washer_sensor_values( mock_instance.get_machine_state.return_value = MachineState.Complete mock_instance.attr_value_to_bool.side_effect = None - mock_instance.get_attribute.side_effect = side_effect_function_open_door + mock_instance.get_door_open.return_value = True state = await update_sensor_state(hass, entity_id, mock_instance) assert state is not None assert state.state == "door_open" @@ -338,8 +324,7 @@ async def test_callback(hass: HomeAssistant, mock_sensor1_api: MagicMock) -> Non state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == thetimestamp.isoformat() mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle - mock_sensor1_api.get_attribute.side_effect = None - mock_sensor1_api.get_attribute.return_value = "60" + mock_sensor1_api.get_time_remaining.return_value = 60 callback() # Test new timestamp when machine starts a cycle. @@ -348,13 +333,13 @@ async def test_callback(hass: HomeAssistant, mock_sensor1_api: MagicMock) -> Non assert state.state != thetimestamp.isoformat() # Test no timestamp change for < 60 seconds time change. - mock_sensor1_api.get_attribute.return_value = "65" + mock_sensor1_api.get_time_remaining.return_value = 65 callback() state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == time # Test timestamp change for > 60 seconds. - mock_sensor1_api.get_attribute.return_value = "125" + mock_sensor1_api.get_time_remaining.return_value = 125 callback() state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") newtime = utc_from_timestamp(as_timestamp(time) + 65) diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index 0d99b0596e3..97d6fde6376 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -175,6 +175,94 @@ 'version': 1, }) # --- +# name: test_full_flow_with_error[WhoisPrivateRegistry-private_registry] + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'example.com', + }), + 'data': dict({ + 'domain': 'example.com', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'whois', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'domain': 'example.com', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'whois', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Example.com', + 'unique_id': 'example.com', + 'version': 1, + }), + 'subentries': tuple( + ), + 'title': 'Example.com', + 'type': , + 'version': 1, + }) +# --- +# name: test_full_flow_with_error[WhoisQuotaExceeded-quota_exceeded] + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'example.com', + }), + 'data': dict({ + 'domain': 'example.com', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'whois', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'domain': 'example.com', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'whois', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Example.com', + 'unique_id': 'example.com', + 'version': 1, + }), + 'subentries': tuple( + ), + 'title': 'Example.com', + 'type': , + 'version': 1, + }) +# --- # name: test_full_user_flow FlowResultSnapshot({ 'context': dict({ diff --git a/tests/components/whois/test_config_flow.py b/tests/components/whois/test_config_flow.py index 35e40c4e809..6ab02887be2 100644 --- a/tests/components/whois/test_config_flow.py +++ b/tests/components/whois/test_config_flow.py @@ -9,6 +9,8 @@ from whois.exceptions import ( UnknownDateFormat, UnknownTld, WhoisCommandFailed, + WhoisPrivateRegistry, + WhoisQuotaExceeded, ) from homeassistant.components.whois.const import DOMAIN @@ -52,6 +54,8 @@ async def test_full_user_flow( (FailedParsingWhoisOutput, "unexpected_response"), (UnknownDateFormat, "unknown_date_format"), (WhoisCommandFailed, "whois_command_failed"), + (WhoisPrivateRegistry, "private_registry"), + (WhoisQuotaExceeded, "quota_exceeded"), ], ) async def test_full_flow_with_error( diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index ca3b0a5dc6e..d3f8fbcc21d 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -99,77 +99,77 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Segment 1 color palette', 'options': list([ - 'Default', - '* Random Cycle', '* Color 1', - '* Colors 1&2', '* Color Gradient', + '* Colors 1&2', '* Colors Only', - 'Party', - 'Cloud', - 'Lava', - 'Ocean', - 'Forest', - 'Rainbow', - 'Rainbow Bands', - 'Sunset', - 'Rivendell', - 'Breeze', - 'Red & Blue', - 'Yellowout', + '* Random Cycle', 'Analogous', - 'Splash', - 'Pastel', - 'Sunset 2', - 'Beach', - 'Vintage', - 'Departure', - 'Landscape', - 'Beech', - 'Sherbet', - 'Hult', - 'Hult 64', - 'Drywet', - 'Jul', - 'Grintage', - 'Rewhi', - 'Tertiary', - 'Fire', - 'Icefire', - 'Cyane', - 'Light Pink', - 'Autumn', - 'Magenta', - 'Magred', - 'Yelmag', - 'Yelblu', - 'Orange & Teal', - 'Tiamat', 'April Night', - 'Orangery', - 'C9', - 'Sakura', - 'Aurora', + 'Aqua Flash', 'Atlantica', + 'Aurora', + 'Aurora 2', + 'Autumn', + 'Beach', + 'Beech', + 'Blink Red', + 'Breeze', + 'C9', 'C9 2', 'C9 New', - 'Temperature', - 'Aurora 2', - 'Retro Clown', 'Candy', - 'Toxy Reaf', + 'Candy2', + 'Cloud', + 'Cyane', + 'Default', + 'Departure', + 'Drywet', 'Fairy Reaf', - 'Semi Blue', - 'Pink Candy', - 'Red Reaf', - 'Aqua Flash', - 'Yelblu Hot', + 'Fire', + 'Forest', + 'Grintage', + 'Hult', + 'Hult 64', + 'Icefire', + 'Jul', + 'Landscape', + 'Lava', + 'Light Pink', 'Lite Light', + 'Magenta', + 'Magred', + 'Ocean', + 'Orange & Teal', + 'Orangery', + 'Party', + 'Pastel', + 'Pink Candy', + 'Rainbow', + 'Rainbow Bands', + 'Red & Blue', 'Red Flash', - 'Blink Red', + 'Red Reaf', 'Red Shift', 'Red Tide', - 'Candy2', + 'Retro Clown', + 'Rewhi', + 'Rivendell', + 'Sakura', + 'Semi Blue', + 'Sherbet', + 'Splash', + 'Sunset', + 'Sunset 2', + 'Temperature', + 'Tertiary', + 'Tiamat', + 'Toxy Reaf', + 'Vintage', + 'Yelblu', + 'Yelblu Hot', + 'Yellowout', + 'Yelmag', ]), }), 'context': , @@ -187,77 +187,77 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'Default', - '* Random Cycle', '* Color 1', - '* Colors 1&2', '* Color Gradient', + '* Colors 1&2', '* Colors Only', - 'Party', - 'Cloud', - 'Lava', - 'Ocean', - 'Forest', - 'Rainbow', - 'Rainbow Bands', - 'Sunset', - 'Rivendell', - 'Breeze', - 'Red & Blue', - 'Yellowout', + '* Random Cycle', 'Analogous', - 'Splash', - 'Pastel', - 'Sunset 2', - 'Beach', - 'Vintage', - 'Departure', - 'Landscape', - 'Beech', - 'Sherbet', - 'Hult', - 'Hult 64', - 'Drywet', - 'Jul', - 'Grintage', - 'Rewhi', - 'Tertiary', - 'Fire', - 'Icefire', - 'Cyane', - 'Light Pink', - 'Autumn', - 'Magenta', - 'Magred', - 'Yelmag', - 'Yelblu', - 'Orange & Teal', - 'Tiamat', 'April Night', - 'Orangery', - 'C9', - 'Sakura', - 'Aurora', + 'Aqua Flash', 'Atlantica', + 'Aurora', + 'Aurora 2', + 'Autumn', + 'Beach', + 'Beech', + 'Blink Red', + 'Breeze', + 'C9', 'C9 2', 'C9 New', - 'Temperature', - 'Aurora 2', - 'Retro Clown', 'Candy', - 'Toxy Reaf', + 'Candy2', + 'Cloud', + 'Cyane', + 'Default', + 'Departure', + 'Drywet', 'Fairy Reaf', - 'Semi Blue', - 'Pink Candy', - 'Red Reaf', - 'Aqua Flash', - 'Yelblu Hot', + 'Fire', + 'Forest', + 'Grintage', + 'Hult', + 'Hult 64', + 'Icefire', + 'Jul', + 'Landscape', + 'Lava', + 'Light Pink', 'Lite Light', + 'Magenta', + 'Magred', + 'Ocean', + 'Orange & Teal', + 'Orangery', + 'Party', + 'Pastel', + 'Pink Candy', + 'Rainbow', + 'Rainbow Bands', + 'Red & Blue', 'Red Flash', - 'Blink Red', + 'Red Reaf', 'Red Shift', 'Red Tide', - 'Candy2', + 'Retro Clown', + 'Rewhi', + 'Rivendell', + 'Sakura', + 'Semi Blue', + 'Sherbet', + 'Splash', + 'Sunset', + 'Sunset 2', + 'Temperature', + 'Tertiary', + 'Tiamat', + 'Toxy Reaf', + 'Vintage', + 'Yelblu', + 'Yelblu Hot', + 'Yellowout', + 'Yelmag', ]), }), 'config_entry_id': , diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 51d4b899d25..c05da654f96 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -55,7 +55,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: [], CONF_REMOVE_HOLIDAYS: [], - CONF_LANGUAGE: "de", + CONF_LANGUAGE: "en_US", }, ) await hass.async_block_till_done() @@ -70,7 +70,48 @@ async def test_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "language": "de", + "language": "en_US", + } + + +async def test_form_province_no_alias(hass: HomeAssistant) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "US", + }, + ) + 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: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "US", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], } diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py index 1e0c9cbebc6..2735175b49b 100644 --- a/tests/components/workday/test_init.py +++ b/tests/components/workday/test_init.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime from freezegun.api import FrozenDateTimeFactory +from holidays.utils import country_holidays from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -50,3 +51,18 @@ async def test_update_options( assert entry_check.state is ConfigEntryState.LOADED state = hass.states.get("binary_sensor.workday_sensor") assert state.state == "off" + + +async def test_workday_subdiv_aliases() -> None: + """Test subdiv aliases in holidays library.""" + + country = country_holidays( + country="FR", + years=2025, + ) + subdiv_aliases = country.get_subdivision_aliases() + assert subdiv_aliases["GES"] == [ # codespell:ignore + "Alsace", + "Champagne-Ardenne", + "Lorraine", + ] diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 018fff33821..125edc547c6 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -121,7 +121,9 @@ def handle_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture -async def init_wyoming_stt(hass: HomeAssistant, stt_config_entry: ConfigEntry): +async def init_wyoming_stt( + hass: HomeAssistant, stt_config_entry: ConfigEntry +) -> ConfigEntry: """Initialize Wyoming STT.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -129,9 +131,13 @@ async def init_wyoming_stt(hass: HomeAssistant, stt_config_entry: ConfigEntry): ): await hass.config_entries.async_setup(stt_config_entry.entry_id) + return stt_config_entry + @pytest.fixture -async def init_wyoming_tts(hass: HomeAssistant, tts_config_entry: ConfigEntry): +async def init_wyoming_tts( + hass: HomeAssistant, tts_config_entry: ConfigEntry +) -> ConfigEntry: """Initialize Wyoming TTS.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -139,11 +145,13 @@ async def init_wyoming_tts(hass: HomeAssistant, tts_config_entry: ConfigEntry): ): await hass.config_entries.async_setup(tts_config_entry.entry_id) + return tts_config_entry + @pytest.fixture async def init_wyoming_wake_word( hass: HomeAssistant, wake_word_config_entry: ConfigEntry -): +) -> ConfigEntry: """Initialize Wyoming Wake Word.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -151,6 +159,8 @@ async def init_wyoming_wake_word( ): await hass.config_entries.async_setup(wake_word_config_entry.entry_id) + return wake_word_config_entry + @pytest.fixture async def init_wyoming_intent( diff --git a/tests/components/wyoming/test_websocket.py b/tests/components/wyoming/test_websocket.py new file mode 100644 index 00000000000..18b43321354 --- /dev/null +++ b/tests/components/wyoming/test_websocket.py @@ -0,0 +1,58 @@ +"""Websocket tests for Wyoming integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + + +async def test_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + init_wyoming_stt: ConfigEntry, + init_wyoming_tts: ConfigEntry, + init_wyoming_wake_word: ConfigEntry, + init_wyoming_intent: ConfigEntry, + init_wyoming_handle: ConfigEntry, +) -> None: + """Test info websocket command.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "wyoming/info"}) + + # result + msg = await client.receive_json() + assert msg["success"] + + info = msg.get("result", {}).get("info", {}) + + # stt (speech-to-text) = asr (automated speech recognition) + assert init_wyoming_stt.entry_id in info + asr_info = info[init_wyoming_stt.entry_id].get("asr", []) + assert len(asr_info) == 1 + assert asr_info[0].get("name") == "Test ASR" + + # tts (text-to-speech) + assert init_wyoming_tts.entry_id in info + tts_info = info[init_wyoming_tts.entry_id].get("tts", []) + assert len(tts_info) == 1 + assert tts_info[0].get("name") == "Test TTS" + + # wake word detection + assert init_wyoming_wake_word.entry_id in info + wake_info = info[init_wyoming_wake_word.entry_id].get("wake", []) + assert len(wake_info) == 1 + assert wake_info[0].get("name") == "Test Wake Word" + + # intent recognition + assert init_wyoming_intent.entry_id in info + intent_info = info[init_wyoming_intent.entry_id].get("intent", []) + assert len(intent_info) == 1 + assert intent_info[0].get("name") == "Test Intent" + + # intent handling + assert init_wyoming_handle.entry_id in info + handle_info = info[init_wyoming_handle.entry_id].get("handle", []) + assert len(handle_info) == 1 + assert handle_info[0].get("name") == "Test Handle" diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index 11a20a62d02..f5625d4e74d 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -694,21 +694,21 @@ async def test_miscale_v1_uuid(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes assert mass_non_stabilized_sensor.state == "86.55" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Smart Scale (B5DC) Mass Non Stabilized" + == "Mi Smart Scale (B5DC) Weight non stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" - mass_sensor = hass.states.get("sensor.mi_smart_scale_b5dc_mass") + mass_sensor = hass.states.get("sensor.mi_smart_scale_b5dc_weight") mass_sensor_attr = mass_sensor.attributes assert mass_sensor.state == "86.55" - assert mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Smart Scale (B5DC) Mass" + assert mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Smart Scale (B5DC) Weight" assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -736,22 +736,23 @@ async def test_miscale_v2_uuid(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 3 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_body_composition_scale_b5dc_mass_non_stabilized" + "sensor.mi_body_composition_scale_b5dc_weight_non_stabilized" ) mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes assert mass_non_stabilized_sensor.state == "85.15" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Body Composition Scale (B5DC) Mass Non Stabilized" + == "Mi Body Composition Scale (B5DC) Weight non stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" - mass_sensor = hass.states.get("sensor.mi_body_composition_scale_b5dc_mass") + mass_sensor = hass.states.get("sensor.mi_body_composition_scale_b5dc_weight") mass_sensor_attr = mass_sensor.attributes assert mass_sensor.state == "85.15" assert ( - mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Body Composition Scale (B5DC) Mass" + mass_sensor_attr[ATTR_FRIENDLY_NAME] + == "Mi Body Composition Scale (B5DC) Weight" ) assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -845,7 +846,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) assert mass_non_stabilized_sensor.state == "86.55" @@ -866,7 +867,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time @@ -896,7 +897,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) assert mass_non_stabilized_sensor.state == "86.55" @@ -917,7 +918,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time @@ -930,7 +931,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time and restore it diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index 73652d9b239..2cfb970928d 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -131,7 +131,51 @@ async def test_flow_abort_without_subscriptions( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, ) -> None: - """Check abort flow if user has no subscriptions.""" + """Check abort flow if user has no subscriptions and no own channel.""" + result = await hass.config_entries.flow.async_init( + "youtube", context={"source": config_entries.SOURCE_USER} + ) + 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"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + 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" + + service = MockYouTube( + channel_fixture="youtube/get_no_channel.json", + subscriptions_fixture="youtube/get_no_subscriptions.json", + ) + with ( + patch("homeassistant.components.youtube.async_setup_entry", return_value=True), + patch( + "homeassistant.components.youtube.config_flow.YouTube", return_value=service + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_channel" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_without_subscriptions( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Check flow continues even without subscriptions since user has their own channel.""" result = await hass.config_entries.flow.async_init( "youtube", context={"source": config_entries.SOURCE_USER} ) @@ -163,8 +207,30 @@ async def test_flow_abort_without_subscriptions( ), ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_subscriptions" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "channels" + + # Verify the form schema contains only the user's own channel + schema = result["data_schema"] + channels = schema.schema[CONF_CHANNELS].config["options"] + assert len(channels) == 1 + assert channels[0]["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert "(Your Channel)" in channels[0]["label"] + + # Test selecting the own channel + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert "result" in result + assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + 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["options"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} @pytest.mark.usefixtures("current_request_with_host") @@ -373,3 +439,112 @@ async def test_options_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_own_channel_included( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test that the user's own channel is included in the list of selectable channels.""" + result = await hass.config_entries.flow.async_init( + "youtube", context={"source": config_entries.SOURCE_USER} + ) + 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"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + 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" + + with ( + patch( + "homeassistant.components.youtube.async_setup_entry", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.youtube.config_flow.YouTube", + return_value=MockYouTube(), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "channels" + + # Verify the form schema contains the user's own channel + schema = result["data_schema"] + channels = schema.schema[CONF_CHANNELS].config["options"] + assert any( + channel["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + and "(Your Channel)" in channel["label"] + for channel in channels + ) + + # Test selecting both own channel and a subscribed channel + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw", "UC_x5XG1OV2P6uZZ5FSM9Ttw"] + }, + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert "result" in result + assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + 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["options"] == { + CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw", "UC_x5XG1OV2P6uZZ5FSM9Ttw"] + } + + +async def test_options_flow_own_channel( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test the options flow includes the user's own channel.""" + await setup_integration() + with patch( + "homeassistant.components.youtube.config_flow.YouTube", + return_value=MockYouTube(), + ): + entry = hass.config_entries.async_entries(DOMAIN)[0] + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Verify the form schema contains the user's own channel + schema = result["data_schema"] + channels = schema.schema[CONF_CHANNELS].config["options"] + assert any( + channel["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + and "(Your Channel)" in channel["label"] + for channel in channels + ) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index ba8aa9ea245..7a599b00a21 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -179,7 +179,16 @@ }), '0x0010': dict({ 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': None, + 'value': list([ + 50, + 79, + 50, + 2, + 0, + 141, + 21, + 0, + ]), }), '0x0011': dict({ 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index e5d588aa1bf..4bc4d6c97cf 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -81,7 +81,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: cluster = zigpy_device.endpoints[1].window_covering cluster.PLUGGED_ATTR_READS = { WCAttrs.current_position_lift_percentage.name: 0, - WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.current_position_tilt_percentage.name: 100, WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), } @@ -115,33 +115,33 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert state assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 100 - assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 58 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 - # test that the state has changed from unavailable to off + # test that the state has changed from open to closed await send_attributes_report( hass, cluster, {WCAttrs.current_position_lift_percentage.id: 100} ) assert hass.states.get(entity_id).state == CoverState.CLOSED - # test to see if it opens + # test that it opens await send_attributes_report( hass, cluster, {WCAttrs.current_position_lift_percentage.id: 0} ) assert hass.states.get(entity_id).state == CoverState.OPEN - # test that the state remains after tilting to 100% - await send_attributes_report( - hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} - ) - assert hass.states.get(entity_id).state == CoverState.OPEN - - # test to see the state remains after tilting to 0% + # test that the state remains after tilting to 0% (open) await send_attributes_report( hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} ) assert hass.states.get(entity_id).state == CoverState.OPEN - # close from UI + # test that the state remains after tilting to 100% (closed) + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} + ) + assert hass.states.get(entity_id).state == CoverState.OPEN + + # close lift from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True @@ -160,6 +160,11 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.CLOSED + # close tilt from UI, needs re-opening first + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) + assert hass.states.get(entity_id).state == CoverState.OPEN with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -185,7 +190,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.CLOSED - # open from UI + # open lift from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True @@ -204,6 +209,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.OPEN + # open tilt from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -229,7 +235,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.OPEN - # set position UI + # set lift position from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -261,6 +267,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.OPEN + # set tilt position from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -281,13 +288,13 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.CLOSING await send_attributes_report( - hass, cluster, {WCAttrs.current_position_lift_percentage.id: 35} + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 35} ) assert hass.states.get(entity_id).state == CoverState.CLOSING await send_attributes_report( - hass, cluster, {WCAttrs.current_position_lift_percentage.id: 53} + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 53} ) assert hass.states.get(entity_id).state == CoverState.OPEN @@ -338,7 +345,7 @@ async def test_cover_failures( # load up cover domain cluster = zigpy_device.endpoints[1].window_covering cluster.PLUGGED_ATTR_READS = { - WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.current_position_tilt_percentage.name: 100, WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, } update_attribute_cache(cluster) @@ -355,7 +362,7 @@ async def test_cover_failures( await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) assert hass.states.get(entity_id).state == CoverState.CLOSED - # test to see if it opens + # test that it opens await send_attributes_report(hass, cluster, {0: 1, 8: 0, 1: 100}) assert hass.states.get(entity_id).state == CoverState.OPEN diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index af81ac0d586..0ff863f0c45 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -49,7 +49,8 @@ def test_detect_radio_hardware(hass: HomeAssistant) -> None: "product": "SkyConnect v1.0", "firmware": "ezsp", }, - version=2, + version=1, + minor_version=4, domain=SKYCONNECT_DOMAIN, options={}, title="Home Assistant SkyConnect", @@ -66,7 +67,8 @@ def test_detect_radio_hardware(hass: HomeAssistant) -> None: "product": "Home Assistant Connect ZBT-1", "firmware": "ezsp", }, - version=2, + version=1, + minor_version=4, domain=SKYCONNECT_DOMAIN, options={}, title="Home Assistant Connect ZBT-1", diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index a28b3c0592a..27276c6905f 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -17,22 +17,20 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": 32.880837, - "longitude": -117.237561, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + } + }, ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index ce7b0e0109e..e4e757ad363 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -509,6 +509,15 @@ def aeotec_smart_switch_7_state_fixture() -> NodeDataType: ) +@pytest.fixture(name="zcombo_smoke_co_alarm_state") +def zcombo_smoke_co_alarm_state_fixture() -> NodeDataType: + """Load node with fixture data for ZCombo-G Smoke/CO Alarm.""" + return cast( + NodeDataType, + load_json_object_fixture("zcombo_smoke_co_alarm_state.json", DOMAIN), + ) + + # model fixtures @@ -554,6 +563,7 @@ def mock_client_fixture( client.connect = AsyncMock(side_effect=connect) client.listen = AsyncMock(side_effect=listen) client.disconnect = AsyncMock(side_effect=disconnect) + client.disable_server_logging = MagicMock() client.driver = Driver( client, copy.deepcopy(controller_state), copy.deepcopy(log_config_state) ) @@ -1252,3 +1262,13 @@ def aeotec_smart_switch_7_fixture( node = Node(client, aeotec_smart_switch_7_state) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="zcombo_smoke_co_alarm") +def zcombo_smoke_co_alarm_fixture( + client: MagicMock, zcombo_smoke_co_alarm_state: NodeDataType +) -> Node: + """Load node for ZCombo-G Smoke/CO Alarm.""" + node = Node(client, zcombo_smoke_co_alarm_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json b/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json new file mode 100644 index 00000000000..c7417859f1c --- /dev/null +++ b/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json @@ -0,0 +1,854 @@ +{ + "nodeId": 3, + "index": 0, + "installerIcon": 3073, + "userIcon": 3073, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 312, + "productId": 3, + "productType": 1, + "firmwareVersion": "11.0.0", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/data/db/devices/0x0138/zcombo-g.json", + "isEmbedded": true, + "manufacturer": "First Alert (BRK Brands Inc)", + "manufacturerId": 312, + "label": "ZCOMBO", + "description": "ZCombo-G Smoke/CO Alarm", + "devices": [ + { + "productType": 1, + "productId": 3 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "wakeup": "WAKEUP\n1. Slide battery door open and then closed with the batteries inserted.", + "inclusion": "ADD\n1. Slide battery door open.\n2. Insert batteries checking the correct orientation.\n3. Press and hold the test button. Keep it held down as you slide the battery drawer closed. You may then release the button.\nNOTE: Use only your finger or thumb on the test button. The use of any other instrument is strictly prohibited", + "exclusion": "REMOVE\n1. Slide battery door open.\n2. Remove and re-insert batteries checking the correct orientation.\n3. Press and hold the test button. Keep it held down as you slide the battery drawer closed. You may then release the button.\nNOTE: Use only your finger or thumb on the test button. The use of any other instrument is strictly prohibited", + "reset": "RESET DEVICE\nIf the device is powered up with the test button held down for 10+ seconds, the device will reset all Z-Wave settings and leave the network.\nUpon completion of the Reset operation, the LED will glow and the horn will sound for ~1 second.\nPlease use this procedure only when the network primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/3886/User_Manual_M08-0456-173833_D2.pdf" + } + }, + "label": "ZCOMBO", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0138:0x0001:0x0003:11.0.0", + "statistics": { + "commandsTX": 1, + "commandsRX": 4, + "commandsDroppedRX": 1, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -79, + "repeaterRSSI": [] + }, + "lastSeen": "2024-11-11T21:36:45.802Z", + "rtt": 28.9, + "rssi": -79 + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-11-11T19:17:39.916Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Supervision Report Timeout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "ZCOMBO will send the message over Supervision Command Class and it will wait for the Supervision report from the Controller for the Supervision report timeout time.", + "label": "Supervision Report Timeout", + "default": 1500, + "min": 500, + "max": 5000, + "unit": "ms", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1500 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Supervision Retry Count", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "If the Supervision report is not received within the Supervision report timeout time, the ZCOMBO will retry sending the message again. Upon exceeding the max retry, the ZCOMBO device will send the next message available in the queue.", + "label": "Supervision Retry Count", + "default": 1, + "min": 0, + "max": 5, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Supervision Wait Time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Before retrying the message, ZCOMBO will wait for the Supervision wait time. Actual wait time is calculated using the formula: Wait Time = Supervision wait time base-value + random-value + (attempt-count x 5 seconds). The random value will be between 100 and 1100 milliseconds.", + "label": "Supervision Wait Time", + "default": 5, + "min": 1, + "max": 60, + "unit": "seconds", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Smoke Alarm", + "propertyKey": "Sensor status", + "propertyName": "Smoke Alarm", + "propertyKeyName": "Sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Sensor status", + "ccSpecific": { + "notificationType": 1 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Smoke detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Smoke Alarm", + "propertyKey": "Alarm status", + "propertyName": "Smoke Alarm", + "propertyKeyName": "Alarm status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm status", + "ccSpecific": { + "notificationType": 1 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "Smoke alarm test", + "6": "Alarm silenced" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Sensor status", + "propertyName": "CO Alarm", + "propertyKeyName": "Sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Sensor status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Carbon monoxide detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Maintenance status", + "propertyName": "CO Alarm", + "propertyKeyName": "Maintenance status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Maintenance status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "5": "Replacement required, End-of-life" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Alarm status", + "propertyName": "CO Alarm", + "propertyKeyName": "Alarm status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Alarm silenced" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "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", + "1": "System hardware failure" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "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 + }, + "value": 0 + }, + { + "endpoint": 0, + "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 + }, + "value": 0 + }, + { + "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": 312 + }, + { + "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": 1 + }, + { + "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": 3 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 92 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 2, + "metadata": { + "type": "number", + "default": 4200, + "readable": false, + "writeable": true, + "min": 4200, + "max": 4200, + "steps": 0, + "stateful": true, + "secret": false + }, + "value": 4200 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "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": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.7" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["11.0", "7.0"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "6.81.6" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "4.3.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 52445 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.7.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 97 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "11.0.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 0 + } + ], + "endpoints": [ + { + "nodeId": 3, + "index": 0, + "installerIcon": 3073, + "userIcon": 3073, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "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": 1, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 2, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 657dd337bf9..93ac52f9041 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -293,3 +293,141 @@ async def test_config_parameter_binary_sensor( state = hass.states.get(binary_sensor_entity_id) assert state assert state.state == STATE_OFF + + +async def test_smoke_co_notification_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zcombo_smoke_co_alarm: Node, + integration: MockConfigEntry, +) -> None: + """Test smoke and CO notification sensors with diagnostic states.""" + # Test smoke alarm sensor + smoke_sensor = "binary_sensor.zcombo_g_smoke_co_alarm_smoke_detected" + state = hass.states.get(smoke_sensor) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.SMOKE + entity_entry = entity_registry.async_get(smoke_sensor) + assert entity_entry + assert entity_entry.entity_category != EntityCategory.DIAGNOSTIC + + # Test smoke alarm diagnostic sensor + smoke_diagnostic = "binary_sensor.zcombo_g_smoke_co_alarm_smoke_alarm_test" + state = hass.states.get(smoke_diagnostic) + assert state + assert state.state == STATE_OFF + entity_entry = entity_registry.async_get(smoke_diagnostic) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + # Test CO alarm sensor + co_sensor = "binary_sensor.zcombo_g_smoke_co_alarm_carbon_monoxide_detected" + state = hass.states.get(co_sensor) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CO + entity_entry = entity_registry.async_get(co_sensor) + assert entity_entry + assert entity_entry.entity_category != EntityCategory.DIAGNOSTIC + + # Test diagnostic entities + entity_ids = [ + "binary_sensor.zcombo_g_smoke_co_alarm_smoke_alarm_test", + "binary_sensor.zcombo_g_smoke_co_alarm_alarm_silenced", + "binary_sensor.zcombo_g_smoke_co_alarm_replacement_required_end_of_life", + "binary_sensor.zcombo_g_smoke_co_alarm_alarm_silenced_2", + "binary_sensor.zcombo_g_smoke_co_alarm_system_hardware_failure", + "binary_sensor.zcombo_g_smoke_co_alarm_low_battery_level", + ] + for entity_id in entity_ids: + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + # Test state updates for smoke alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Smoke Alarm", + "propertyKey": "Sensor status", + "newValue": 2, + "prevValue": 0, + "propertyName": "Smoke Alarm", + "propertyKeyName": "Sensor status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(smoke_sensor) + assert state is not None, "Smoke sensor state should not be None" + assert state.state == STATE_ON, ( + f"Expected smoke sensor state to be 'on', got '{state.state}'" + ) + + # Test state updates for CO alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "CO Alarm", + "propertyKey": "Sensor status", + "newValue": 2, + "prevValue": 0, + "propertyName": "CO Alarm", + "propertyKeyName": "Sensor status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(co_sensor) + assert state is not None, "CO sensor state should not be None" + assert state.state == STATE_ON, ( + f"Expected CO sensor state to be 'on', got '{state.state}'" + ) + + # Test diagnostic state updates for smoke alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Smoke Alarm", + "propertyKey": "Alarm status", + "newValue": 3, + "prevValue": 0, + "propertyName": "Smoke Alarm", + "propertyKeyName": "Alarm status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(smoke_diagnostic) + assert state is not None, "Smoke diagnostic state should not be None" + assert state.state == STATE_ON, ( + f"Expected smoke diagnostic state to be 'on', got '{state.state}'" + ) diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 5d711528a28..f312284d897 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -42,6 +42,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -56,6 +57,12 @@ from .common import ( ) +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + async def test_thermostat_v2( hass: HomeAssistant, client, diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index b13d4f9787f..13f519725fd 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -2,6 +2,7 @@ import logging +import pytest from zwave_js_server.const import ( CURRENT_STATE_PROPERTY, CURRENT_VALUE_PROPERTY, @@ -35,6 +36,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant @@ -50,6 +52,12 @@ FIBARO_FGR_223_SHUTTER_COVER_ENTITY = "cover.fgr_223_test_cover" LOGGER.setLevel(logging.DEBUG) +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.COVER] + + async def test_window_cover( hass: HomeAssistant, client, chain_actuator_zws12, integration ) -> None: diff --git a/tests/components/zwave_js/test_event.py b/tests/components/zwave_js/test_event.py index 1db02662f4e..84b1ade2632 100644 --- a/tests/components/zwave_js/test_event.py +++ b/tests/components/zwave_js/test_event.py @@ -3,11 +3,12 @@ from datetime import timedelta from freezegun import freeze_time +import pytest from zwave_js_server.event import Event from homeassistant.components.event import ATTR_EVENT_TYPE from homeassistant.components.zwave_js.const import ATTR_VALUE -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -15,6 +16,12 @@ BASIC_EVENT_VALUE_ENTITY = "event.honeywell_in_wall_smart_fan_control_event_valu CENTRAL_SCENE_ENTITY = "event.node_51_scene_002" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.EVENT] + + async def test_basic( hass: HomeAssistant, client, fan_honeywell_39358, integration ) -> None: diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index 0bb6376a02b..8cdaef3e63d 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -6,11 +6,18 @@ import pytest from zwave_js_server.const import CommandClass from zwave_js_server.event import Event +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import async_capture_events +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [] + + async def test_scenes( hass: HomeAssistant, hank_binary_switch, integration, client ) -> None: @@ -244,6 +251,7 @@ async def test_notifications( assert events[2].data["command_class_name"] == "Multilevel Switch" +@pytest.mark.parametrize("platforms", [[Platform.SWITCH]]) async def test_value_updated( hass: HomeAssistant, vision_security_zl7432, integration, client ) -> None: diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 2551fc7b34a..25ab6a87200 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -29,12 +29,19 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.FAN] + + async def test_generic_fan( hass: HomeAssistant, client, fan_generic, integration ) -> None: diff --git a/tests/components/zwave_js/test_humidifier.py b/tests/components/zwave_js/test_humidifier.py index 261e09babee..78ea7899287 100644 --- a/tests/components/zwave_js/test_humidifier.py +++ b/tests/components/zwave_js/test_humidifier.py @@ -1,5 +1,6 @@ """Test the Z-Wave JS humidifier platform.""" +import pytest from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.humidity_control import HumidityControlMode from zwave_js_server.event import Event @@ -22,12 +23,19 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from .common import DEHUMIDIFIER_ADC_T3000_ENTITY, HUMIDIFIER_ADC_T3000_ENTITY +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.HUMIDIFIER] + + async def test_humidifier( hass: HomeAssistant, client, climate_adc_t3000, integration ) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 65e3518956e..dd3fd44f3ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -852,7 +852,7 @@ def hass_client_no_auth( @pytest.fixture def current_request() -> Generator[MagicMock]: """Mock current request.""" - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mocked_request = make_mocked_request( "GET", "/some/request", diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index c69f039027e..3496c41ecf4 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -494,6 +494,29 @@ async def test_async_get_area_by_name(area_registry: ar.AreaRegistry) -> None: assert area_registry.async_get_area_by_name("M o c k 1").normalized_name == "mock1" +async def test_async_get_areas_by_alias( + area_registry: ar.AreaRegistry, +) -> None: + """Make sure we can get the areas by alias.""" + area1 = area_registry.async_create("Mock1", aliases=("alias_1", "alias_2")) + area2 = area_registry.async_create("Mock2", aliases=("alias_1", "alias_3")) + + assert len(area_registry.areas) == 2 + + alias1_list = area_registry.async_get_areas_by_alias("A l i a s_1") + alias2_list = area_registry.async_get_areas_by_alias("A l i a s_2") + alias3_list = area_registry.async_get_areas_by_alias("A l i a s_3") + + assert len(alias1_list) == 2 + assert len(alias2_list) == 1 + assert len(alias3_list) == 1 + + assert area1 in alias1_list + assert area1 in alias2_list + assert area2 in alias1_list + assert area2 in alias3_list + + async def test_async_get_area_by_name_not_found(area_registry: ar.AreaRegistry) -> None: """Make sure we return None for non-existent areas.""" area_registry.async_create("Mock1") diff --git a/tests/helpers/test_floor_registry.py b/tests/helpers/test_floor_registry.py index 6a672399522..5ebd63ae302 100644 --- a/tests/helpers/test_floor_registry.py +++ b/tests/helpers/test_floor_registry.py @@ -327,7 +327,7 @@ async def test_loading_floors_from_storage( assert len(registry.floors) == 1 -async def test_getting_floor(floor_registry: fr.FloorRegistry) -> None: +async def test_getting_floor_by_name(floor_registry: fr.FloorRegistry) -> None: """Make sure we can get the floors by name.""" floor = floor_registry.async_create("First floor") floor2 = floor_registry.async_get_floor_by_name("first floor") @@ -341,6 +341,27 @@ async def test_getting_floor(floor_registry: fr.FloorRegistry) -> None: assert get_floor == floor +async def test_async_get_floors_by_alias( + floor_registry: fr.FloorRegistry, +) -> None: + """Make sure we can get the floors by alias.""" + floor1 = floor_registry.async_create("First floor", aliases=("alias_1", "alias_2")) + floor2 = floor_registry.async_create("Second floor", aliases=("alias_1", "alias_3")) + + alias1_list = floor_registry.async_get_floors_by_alias("A l i a s_1") + alias2_list = floor_registry.async_get_floors_by_alias("A l i a s_2") + alias3_list = floor_registry.async_get_floors_by_alias("A l i a s_3") + + assert len(alias1_list) == 2 + assert len(alias2_list) == 1 + assert len(alias3_list) == 1 + + assert floor1 in alias1_list + assert floor1 in alias2_list + assert floor2 in alias1_list + assert floor2 in alias3_list + + async def test_async_get_floor_by_name_not_found( floor_registry: fr.FloorRegistry, ) -> None: diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 45ed009fcf1..26c357c4b0a 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -576,6 +576,10 @@ async def test_assist_api_prompt( ) ) exposed_entities_prompt = """An overview of the areas and the devices in this smart home: +- names: '1' + domain: light + state: unavailable + areas: Test Area 2 - names: Kitchen domain: light state: 'on' @@ -590,18 +594,6 @@ async def test_assist_api_prompt( domain: light state: unavailable areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name - names: Test Device 2 domain: light state: unavailable @@ -614,13 +606,55 @@ async def test_assist_api_prompt( domain: light state: unavailable areas: Test Area 2 +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name - names: Unnamed Device domain: light state: unavailable areas: Test Area 2 +""" + stateless_exposed_entities_prompt = """An overview of the areas and the devices in this smart home: - names: '1' domain: light - state: unavailable + areas: Test Area 2 +- names: Kitchen + domain: light +- names: Living Room + domain: light + areas: Test Area, Alternative name +- names: Test Device, my test light + domain: light + areas: Test Area, Alternative name +- names: Test Device 2 + domain: light + areas: Test Area 2 +- names: Test Device 3 + domain: light + areas: Test Area 2 +- names: Test Device 4 + domain: light + areas: Test Area 2 +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Unnamed Device + domain: light areas: Test Area 2 """ first_part_prompt = ( @@ -640,7 +674,7 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Verify that the get_home_state tool returns the same results as the exposed_entities_prompt @@ -663,7 +697,7 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Add floor @@ -678,7 +712,7 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Register device for timers @@ -689,7 +723,7 @@ async def test_assist_api_prompt( assert api.api_prompt == ( f"""{first_part_prompt} {area_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 3064b215f2f..46d84ea768d 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -538,7 +538,7 @@ async def test_get_url(hass: HomeAssistant) -> None: "homeassistant.helpers.network._get_request_host", return_value="example.com", ), - patch("homeassistant.components.http.current_request"), + patch("homeassistant.helpers.http.current_request"), ): assert get_url(hass, require_current_request=True) == "https://example.com" assert ( @@ -554,7 +554,7 @@ async def test_get_url(hass: HomeAssistant) -> None: "homeassistant.helpers.network._get_request_host", return_value="example.local", ), - patch("homeassistant.components.http.current_request"), + patch("homeassistant.helpers.http.current_request"), ): assert get_url(hass, require_current_request=True) == "http://example.local" @@ -592,7 +592,7 @@ async def test_get_request_host_with_port(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy( CIMultiDict({hdrs.HOST: "example.com:8123"}) @@ -609,7 +609,7 @@ async def test_get_request_host_without_port(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "example.com"})) mock_request.url = URL("http://example.com/test/request") @@ -624,7 +624,7 @@ async def test_get_request_ipv6_address(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "[::1]:8123"})) mock_request.url = URL("http://[::1]:8123/test/request") @@ -639,7 +639,7 @@ async def test_get_request_ipv6_address_without_port(hass: HomeAssistant) -> Non with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "[::1]"})) mock_request.url = URL("http://[::1]/test/request") @@ -654,7 +654,7 @@ async def test_get_request_host_no_host_header(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict()) mock_request.url = URL("/test/request") diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index df589a41daa..4c707590528 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -494,7 +494,7 @@ async def test_calling_service_response_data_in_scopes(hass: HomeAssistant) -> N assert result.variables["my_response"] == expected_var expected_trace = { - "0": [{"variables": {"my_response": expected_var}}], + "0": [{"variables": {"my_response": expected_var, "state": "off"}}], "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/1": [ { @@ -1797,7 +1797,7 @@ async def test_wait_in_sequence(hass: HomeAssistant) -> None: assert result.variables["wait"] == expected_var expected_trace = { - "0": [{"variables": {"wait": expected_var}}], + "0": [{"variables": {"wait": expected_var, "state": "off"}}], "0/sequence/0": [{"variables": {"state": "off"}}], "0/sequence/1": [ { @@ -1840,7 +1840,7 @@ async def test_wait_in_parallel(hass: HomeAssistant) -> None: assert "wait" not in result.variables expected_trace = { - "0": [{}], + "0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/1": [ { @@ -5277,11 +5277,23 @@ async def test_set_variable( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test setting variables in scripts.""" - alias = "variables step" sequence = cv.SCRIPT_SCHEMA( [ - {"alias": alias, "variables": {"variable": "value"}}, - {"action": "test.script", "data": {"value": "{{ variable }}"}}, + {"alias": "variables", "variables": {"x": 1, "y": 1}}, + { + "alias": "scope", + "sequence": [ + {"variables": {"y": 3, "z": 3}}, + { + "action": "test.script", + "data": {"value": "x={{ x }}, y={{ y }}, z={{ z }}"}, + }, + ], + }, + { + "action": "test.script", + "data": {"value": "x={{ x }}, y={{ y }}, z={{ z }}"}, + }, ] ) script_obj = script.Script(hass, sequence, "test script", "test_domain") @@ -5291,18 +5303,36 @@ async def test_set_variable( await script_obj.async_run(context=Context()) await hass.async_block_till_done() - assert mock_calls[0].data["value"] == "value" - assert f"Executing step {alias}" in caplog.text + assert len(mock_calls) == 2 + assert mock_calls[0].data["value"] == "x=1, y=3, z=3" + assert mock_calls[1].data["value"] == "x=1, y=3, z=3" + + assert "Executing step variables" in caplog.text expected_trace = { - "0": [{"variables": {"variable": "value"}}], - "1": [ + "0": [{"variables": {"x": 1, "y": 1}}], + "1": [{"variables": {"y": 3, "z": 3}}], + "1/sequence/0": [{"variables": {"y": 3, "z": 3}}], + "1/sequence/1": [ { "result": { "params": { "domain": "test", "service": "script", - "service_data": {"value": "value"}, + "service_data": {"value": "x=1, y=3, z=3"}, + "target": {}, + }, + "running_script": False, + }, + } + ], + "2": [ + { + "result": { + "params": { + "domain": "test", + "service": "script", + "service_data": {"value": "x=1, y=3, z=3"}, "target": {}, }, "running_script": False, @@ -5823,14 +5853,16 @@ async def test_stop_action_subscript( ) +@pytest.mark.parametrize(("var", "response"), [(1, "If: Then"), (2, "Testing 123")]) @pytest.mark.parametrize( - ("var", "response"), - [(1, "If: Then"), (2, "Testing 123")], + ("script_mode", "max_runs"), [("single", 1), ("parallel", 2), ("queued", 2)] ) async def test_stop_action_response_variables( hass: HomeAssistant, var: int, response: str, + script_mode, + max_runs, ) -> None: """Test setting stop response_variable in a subscript.""" sequence = cv.SCRIPT_SCHEMA( @@ -5849,7 +5881,14 @@ async def test_stop_action_response_variables( {"stop": "In the name of love", "response_variable": "output"}, ] ) - script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + script_obj = script.Script( + hass, + sequence, + "Test Name", + "test_domain", + script_mode=script_mode, + max_runs=max_runs, + ) run_vars = MappingProxyType({"var": var}) result = await script_obj.async_run(run_vars, context=Context()) @@ -5899,7 +5938,9 @@ async def test_stop_action_nested_response_variables( "variables": {"var": var, "output": {"value": "Testing 123"}}, } ], - "1": [{"result": {"choice": choice}}], + "1": [ + {"result": {"choice": choice}, "variables": {"output": {"value": response}}} + ], "1/if": [{"result": {"result": if_result}}], "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], f"1/{choice}/0": [{"variables": {"output": {"value": response}}}], diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index e4e73fc52d9..89d1c307fd7 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6790,6 +6790,184 @@ def test_flatten(hass: HomeAssistant) -> None: template.Template("{{ flatten() }}", hass).async_render() +def test_intersect(hass: HomeAssistant) -> None: + """Test the intersect function and filter.""" + assert list( + template.Template( + "{{ intersect([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5]) + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | intersect([1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5]) + + assert list( + template.Template( + "{{ intersect(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["b", "c"]) + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | intersect(['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["b", "c"]) + + assert ( + template.Template("{{ intersect([], [1, 2, 3]) }}", hass).async_render() == [] + ) + + assert ( + template.Template("{{ [] | intersect([1, 2, 3]) }}", hass).async_render() == [] + ) + + with pytest.raises(TemplateError, match="intersect expected a list, got str"): + template.Template("{{ 'string' | intersect([1, 2, 3]) }}", hass).async_render() + + with pytest.raises(TemplateError, match="intersect expected a list, got str"): + template.Template("{{ [1, 2, 3] | intersect('string') }}", hass).async_render() + + +def test_difference(hass: HomeAssistant) -> None: + """Test the difference function and filter.""" + assert list( + template.Template( + "{{ difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == [10] + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | difference([1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == [10] + + assert list( + template.Template( + "{{ difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == ["a"] + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | difference(['b', 'c', 'd']) }}", hass + ).async_render() + ) == ["a"] + + assert ( + template.Template("{{ difference([], [1, 2, 3]) }}", hass).async_render() == [] + ) + + assert ( + template.Template("{{ [] | difference([1, 2, 3]) }}", hass).async_render() == [] + ) + + with pytest.raises(TemplateError, match="difference expected a list, got str"): + template.Template("{{ 'string' | difference([1, 2, 3]) }}", hass).async_render() + + with pytest.raises(TemplateError, match="difference expected a list, got str"): + template.Template("{{ [1, 2, 3] | difference('string') }}", hass).async_render() + + +def test_union(hass: HomeAssistant) -> None: + """Test the union function and filter.""" + assert list( + template.Template( + "{{ union([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5, 10, 11, 99]) + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | union([1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5, 10, 11, 99]) + + assert list( + template.Template( + "{{ union(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "b", "c", "d"]) + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | union(['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "b", "c", "d"]) + + assert list( + template.Template("{{ union([], [1, 2, 3]) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template("{{ [] | union([1, 2, 3]) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + with pytest.raises(TemplateError, match="union expected a list, got str"): + template.Template("{{ 'string' | union([1, 2, 3]) }}", hass).async_render() + + with pytest.raises(TemplateError, match="union expected a list, got str"): + template.Template("{{ [1, 2, 3] | union('string') }}", hass).async_render() + + +def test_symmetric_difference(hass: HomeAssistant) -> None: + """Test the symmetric_difference function and filter.""" + assert list( + template.Template( + "{{ symmetric_difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", + hass, + ).async_render() + ) == unordered([10, 11, 99]) + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | symmetric_difference([1, 2, 3, 4, 5, 11, 99]) }}", + hass, + ).async_render() + ) == unordered([10, 11, 99]) + + assert list( + template.Template( + "{{ symmetric_difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "d"]) + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | symmetric_difference(['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "d"]) + + assert list( + template.Template( + "{{ symmetric_difference([], [1, 2, 3]) }}", hass + ).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template( + "{{ [] | symmetric_difference([1, 2, 3]) }}", hass + ).async_render() + ) == unordered([1, 2, 3]) + + with pytest.raises( + TemplateError, match="symmetric_difference expected a list, got str" + ): + template.Template( + "{{ 'string' | symmetric_difference([1, 2, 3]) }}", hass + ).async_render() + + with pytest.raises( + TemplateError, match="symmetric_difference expected a list, got str" + ): + template.Template( + "{{ [1, 2, 3] | symmetric_difference('string') }}", hass + ).async_render() + + def test_md5(hass: HomeAssistant) -> None: """Test the md5 function and filter.""" assert ( diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index e52a2cc6567..e9b6f4f718f 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -26,12 +26,10 @@ def reset_log_level() -> Generator[None]: @pytest.fixture -def provider(hass: HomeAssistant) -> hass_auth.HassAuthProvider: +async def provider(hass: HomeAssistant) -> hass_auth.HassAuthProvider: """Home Assistant auth provider.""" - provider = hass.loop.run_until_complete( - register_auth_provider(hass, {"type": "homeassistant"}) - ) - hass.loop.run_until_complete(provider.async_initialize()) + provider = await register_auth_provider(hass, {"type": "homeassistant"}) + await provider.async_initialize() return provider diff --git a/tests/test_block_async_io.py b/tests/test_block_async_io.py index f42fbb9f4ef..337e5500718 100644 --- a/tests/test_block_async_io.py +++ b/tests/test_block_async_io.py @@ -459,3 +459,14 @@ async def test_open_calls_ignored_in_tests(caplog: pytest.LogCaptureFixture) -> pass assert "Detected blocking call to open with args" not in caplog.text + + +async def test_protect_loop_set_default_verify_paths( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test SSLContext.set_default_verify_paths calls in the loop are logged.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + context = ssl.create_default_context() + context.set_default_verify_paths() + assert "Detected blocking call to set_default_verify_paths" in caplog.text diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1fb87ac5ef6..ca75dc51c56 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -252,8 +252,8 @@ async def test_setup_after_deps_all_present(hass: HomeAssistant) -> None: @pytest.mark.parametrize("load_registries", [False]) -async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: - """Test after_dependencies are ignored in stage 1.""" +async def test_setup_after_deps_in_stage_1(hass: HomeAssistant) -> None: + """Test after_dependencies are promoted in stage 1.""" # This test relies on this assert "cloud" in bootstrap.STAGE_1_INTEGRATIONS order = [] @@ -295,7 +295,7 @@ async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: assert "normal_integration" in hass.config.components assert "cloud" in hass.config.components - assert order == ["cloud", "an_after_dep", "normal_integration"] + assert order == ["an_after_dep", "normal_integration", "cloud"] @pytest.mark.parametrize("load_registries", [False]) @@ -304,7 +304,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( ) -> None: """Ensure we preload manifests for after deps even if they are not setup. - Its important that we preload the after dep manifests even if they are not setup + It's important that we preload the after dep manifests even if they are not setup since we will always have to check their requirements since any integration that lists an after dep may import it and we have to ensure requirements are up to date before the after dep can be imported. @@ -371,7 +371,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( assert "an_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep_of_after_dep" not in hass.config.components - assert order == ["cloud", "normal_integration"] + assert order == ["normal_integration", "cloud"] assert loader.async_get_loaded_integration(hass, "an_after_dep") is not None assert ( loader.async_get_loaded_integration(hass, "an_after_dep_of_after_dep") @@ -456,9 +456,9 @@ async def test_setup_frontend_before_recorder(hass: HomeAssistant) -> None: assert order == [ "http", + "an_after_dep", "frontend", "recorder", - "an_after_dep", "normal_integration", ] @@ -1577,8 +1577,10 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> assert not isinstance(integrations_or_excs, Exception) integrations[domain] = integration - integrations_all_dependencies = await loader.resolve_integrations_dependencies( - hass, integrations.values() + integrations_all_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, integrations.values(), ignore_exceptions=True + ) ) all_integrations = integrations.copy() all_integrations.update( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 788225365e0..2d9d18a067d 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -57,7 +57,6 @@ from .common import ( MockPlatform, async_capture_events, async_fire_time_changed, - async_get_persistent_notifications, flush_store, mock_config_flow, mock_integration, @@ -1368,136 +1367,6 @@ async def test_async_forward_entry_setup_deprecated( ) in caplog.text -async def test_discovery_notification( - hass: HomeAssistant, manager: config_entries.ConfigEntries -) -> None: - """Test that we create/dismiss a notification when source is discovery.""" - mock_integration(hass, MockModule("test")) - mock_platform(hass, "test.config_flow", None) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 5 - - async def async_step_discovery(self, discovery_info): - """Test discovery step.""" - return self.async_show_form(step_id="discovery_confirm") - - async def async_step_discovery_confirm(self, discovery_info): - """Test discovery confirm step.""" - return self.async_create_entry(title="Test Title", data={"token": "abcd"}) - - with mock_config_flow("test", TestFlow): - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - - # Start first discovery flow to assert that discovery notification fires - flow1 = await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - - # Start a second discovery flow so we can finish the first and assert that - # the discovery notification persists until the second one is complete - flow2 = await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - - flow1 = await hass.config_entries.flow.async_configure(flow1["flow_id"], {}) - assert flow1["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - - flow2 = await hass.config_entries.flow.async_configure(flow2["flow_id"], {}) - assert flow2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - - -async def test_reauth_notification(hass: HomeAssistant) -> None: - """Test that we create/dismiss a notification when source is reauth.""" - mock_integration(hass, MockModule("test")) - mock_platform(hass, "test.config_flow", None) - - entry = MockConfigEntry(title="test_title", domain="test") - entry.add_to_hass(hass) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 5 - - async def async_step_user(self, user_input): - """Test user step.""" - return self.async_show_form(step_id="user_confirm") - - async def async_step_user_confirm(self, user_input): - """Test user confirm step.""" - return self.async_show_form(step_id="user_confirm") - - async def async_step_reauth(self, user_input): - """Test reauth step.""" - return self.async_show_form(step_id="reauth_confirm") - - async def async_step_reauth_confirm(self, user_input): - """Test reauth confirm step.""" - return self.async_abort(reason="test") - - with mock_config_flow("test", TestFlow): - # Start user flow to assert that reconfigure notification doesn't fire - await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_USER} - ) - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" not in notifications - - # Start first reauth flow to assert that reconfigure notification fires - flow1 = await hass.config_entries.flow.async_init( - "test", - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": entry.entry_id, - }, - ) - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" in notifications - - # Start a second reauth flow so we can finish the first and assert that - # the reconfigure notification persists until the second one is complete - flow2 = await hass.config_entries.flow.async_init( - "test", - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": entry.entry_id, - }, - ) - - flow1 = await hass.config_entries.flow.async_configure(flow1["flow_id"], {}) - assert flow1["type"] == data_entry_flow.FlowResultType.ABORT - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" in notifications - - flow2 = await hass.config_entries.flow.async_configure(flow2["flow_id"], {}) - assert flow2["type"] == data_entry_flow.FlowResultType.ABORT - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" not in notifications - - async def test_reauth_issue( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -1544,30 +1413,6 @@ async def test_reauth_issue( assert len(issue_registry.issues) == 0 -async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: - """Test that we not create a notification when discovery is aborted.""" - mock_integration(hass, MockModule("test")) - mock_platform(hass, "test.config_flow", None) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 5 - - async def async_step_discovery(self, discovery_info): - """Test discovery step.""" - return self.async_abort(reason="test") - - with mock_config_flow("test", TestFlow): - await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - - await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is None - - async def test_loading_default_config(hass: HomeAssistant) -> None: """Test loading the default config.""" manager = config_entries.ConfigEntries(hass, {}) @@ -4265,10 +4110,6 @@ async def test_partial_flows_hidden( # While it's blocked it shouldn't be visible or trigger discovery notifications assert len(hass.config_entries.flow.async_progress()) == 0 - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - # Let the flow init complete pause_discovery.set() @@ -4278,10 +4119,6 @@ async def test_partial_flows_hidden( assert result["type"] == data_entry_flow.FlowResultType.FORM assert len(hass.config_entries.flow.async_progress()) == 1 - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - async def test_async_setup_init_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries @@ -6566,7 +6403,7 @@ async def test_update_subentry_and_abort( class SubentryFlowHandler(config_entries.ConfigSubentryFlow): async def async_step_reconfigure(self, user_input=None): return self.async_update_and_abort( - self._get_reconfigure_entry(), + self._get_entry(), self._get_reconfigure_subentry(), **kwargs, ) @@ -8158,10 +7995,10 @@ async def test_get_reconfigure_entry( assert result["reason"] == "Source is user, expected reconfigure: -" -async def test_subentry_get_reconfigure_entry( +async def test_subentry_get_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: - """Test subentry _get_reconfigure_entry and _get_reconfigure_subentry behavior.""" + """Test subentry _get_entry and _get_reconfigure_subentry behavior.""" subentry_id = "mock_subentry_id" entry = MockConfigEntry( data={}, @@ -8198,13 +8035,13 @@ async def test_subentry_get_reconfigure_entry( async def _async_step_confirm(self): """Confirm input.""" try: - entry = self._get_reconfigure_entry() + entry = self._get_entry() except ValueError as err: reason = str(err) else: reason = f"Found entry {entry.title}" try: - entry_id = self._reconfigure_entry_id + entry_id = self._entry_id except ValueError: reason = f"{reason}: -" else: @@ -8233,7 +8070,7 @@ async def test_subentry_get_reconfigure_entry( ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: return {"test": TestFlow.SubentryFlowHandler} - # A reconfigure flow finds the config entry + # A reconfigure flow finds the config entry and subentry with mock_config_flow("test", TestFlow): result = await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) assert ( @@ -8255,14 +8092,14 @@ async def test_subentry_get_reconfigure_entry( == "Found entry entry_title: mock_entry_id/Subentry not found: 01JRemoved" ) - # A user flow does not have access to the config entry or subentry + # A user flow finds the config entry but not the subentry with mock_config_flow("test", TestFlow): result = await manager.subentries.async_init( (entry.entry_id, "test"), context={"source": config_entries.SOURCE_USER} ) assert ( result["reason"] - == "Source is user, expected reconfigure: -/Source is user, expected reconfigure: -" + == "Found entry entry_title: mock_entry_id/Source is user, expected reconfigure: -" ) @@ -8960,15 +8797,17 @@ async def test_add_description_placeholder_automatically_not_overwrites( @pytest.mark.parametrize( - ("domain", "expected_log"), + ("domain", "source", "expected_log"), [ - ("some_integration", True), - ("mobile_app", False), + ("some_integration", config_entries.SOURCE_USER, True), + ("some_integration", config_entries.SOURCE_IGNORE, False), + ("mobile_app", config_entries.SOURCE_USER, False), ], ) async def test_create_entry_existing_unique_id( hass: HomeAssistant, domain: str, + source: str, expected_log: bool, caplog: pytest.LogCaptureFixture, ) -> None: @@ -8979,6 +8818,7 @@ async def test_create_entry_existing_unique_id( entry_id="01J915Q6T9F6G5V0QJX6HBC94T", data={"host": "any", "port": 123}, unique_id="mock-unique-id", + source=source, ) entry.add_to_hass(hass) diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 74a55cb4989..86ba5257001 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -133,6 +133,61 @@ async def test_show_form(manager: MockFlowManager) -> None: assert form["errors"] == {"username": "Should be unique."} +async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) -> None: + """Test that we can show a form with suggested values.""" + schema = vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + vol.Required("section_1"): data_entry_flow.section( + vol.Schema( + { + vol.Optional("full_name"): str, + } + ), + {"collapsed": False}, + ), + } + ) + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + data_schema = self.add_suggested_values_to_schema( + schema, + { + "username": "doej", + "password": "verySecret1", + "section_1": {"full_name": "John Doe"}, + }, + ) + return self.async_show_form( + step_id="init", + data_schema=data_schema, + ) + + form = await manager.async_init("test") + assert form["type"] == data_entry_flow.FlowResultType.FORM + assert form["data_schema"].schema == schema.schema + markers = list(form["data_schema"].schema) + assert len(markers) == 3 + assert markers[0] == "username" + assert markers[0].description == {"suggested_value": "doej"} + assert markers[1] == "password" + assert markers[1].description == {"suggested_value": "verySecret1"} + assert markers[2] == "section_1" + section_validator = form["data_schema"].schema["section_1"] + assert isinstance(section_validator, data_entry_flow.section) + # The section class was not replaced + assert section_validator is schema.schema["section_1"] + # The section schema was not replaced + assert section_validator.schema is schema.schema["section_1"].schema + section_markers = list(section_validator.schema.schema) + assert len(section_markers) == 1 + assert section_markers[0] == "full_name" + assert section_markers[0].description == {"suggested_value": "John Doe"} + + async def test_abort_removes_instance(manager: MockFlowManager) -> None: """Test that abort removes the flow from progress.""" diff --git a/tests/test_setup.py b/tests/test_setup.py index bb221c7cb4c..084b657a2f2 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -353,6 +353,76 @@ async def test_component_not_setup_missing_dependencies(hass: HomeAssistant) -> assert await setup.async_setup_component(hass, "comp2", {}) +async def test_component_not_setup_already_setup_dependencies( + hass: HomeAssistant, +) -> None: + """Test we do not set up component dependencies if they are already set up.""" + mock_integration( + hass, + MockModule( + "comp", + dependencies=["dep1"], + partial_manifest={"after_dependencies": ["dep2"]}, + ), + ) + mock_integration(hass, MockModule("dep1")) + mock_integration(hass, MockModule("dep2")) + + setup.async_set_domains_to_be_loaded(hass, {"comp", "dep2"}) + + hass.config.components.add("dep1") + hass.config.components.add("dep2") + + with patch( + "homeassistant.setup.async_setup_component", + side_effect=setup.async_setup_component, + ) as mock_setup: + await mock_setup(hass, "comp", {}) + + assert mock_setup.call_count == 1 + + +@pytest.mark.usefixtures("mock_handlers") +async def test_component_setup_dependencies_with_config_entry( + hass: HomeAssistant, +) -> None: + """Test we wait for a dependency with config entry.""" + calls: list[str] = [] + + async def mock_async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + await asyncio.sleep(0) + calls.append("entry") + return True + + mock_integration(hass, MockModule("comp", async_setup_entry=mock_async_setup_entry)) + mock_platform(hass, "comp.config_flow", None) + MockConfigEntry(domain="comp").add_to_hass(hass) + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + calls.append("comp") + return True + + mock_integration( + hass, + MockModule("comp2", dependencies=["comp"], async_setup=mock_async_setup), + ) + mock_integration( + hass, + MockModule("comp3", dependencies=["comp"], async_setup=mock_async_setup), + ) + + await asyncio.gather( + setup.async_setup_component(hass, "comp2", {}), + setup.async_setup_component(hass, "comp3", {}), + ) + + assert "comp" in hass.config.components + assert "comp2" in hass.config.components + assert "comp3" in hass.config.components + + assert calls == ["entry", "comp", "comp"] + + async def test_component_failing_setup(hass: HomeAssistant) -> None: """Test component that fails setup.""" mock_integration(hass, MockModule("comp", setup=lambda hass, config: False)) @@ -458,6 +528,29 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: assert not hass.data[setup.DATA_SETUP_DONE] +async def test_component_setup_after_dependencies(hass: HomeAssistant) -> None: + """Test that after dependencies are set up before the component.""" + mock_integration(hass, MockModule("dep")) + mock_integration( + hass, MockModule("comp", partial_manifest={"after_dependencies": ["dep"]}) + ) + mock_integration( + hass, MockModule("comp2", partial_manifest={"after_dependencies": ["dep"]}) + ) + + setup.async_set_domains_to_be_loaded(hass, {"comp"}) + + assert await setup.async_setup_component(hass, "comp", {}) + assert "comp" in hass.config.components + assert "dep" not in hass.config.components + + setup.async_set_domains_to_be_loaded(hass, {"comp2", "dep"}) + + assert await setup.async_setup_component(hass, "comp2", {}) + assert "comp2" in hass.config.components + assert "dep" in hass.config.components + + async def test_component_setup_with_validation_and_dependency( hass: HomeAssistant, ) -> None: diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index 0b8fd20a7c0..0bada601a3b 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -9,9 +9,9 @@ from aiohttp import web import pytest import pytest_socket -from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.helpers import translation +from homeassistant.helpers.http import HomeAssistantView from homeassistant.setup import async_setup_component from .common import MockModule, mock_integration diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index d213a68d7f2..ba473ee0c58 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -160,6 +160,10 @@ async def test_catch_log_exception_catches_and_logs() -> None: @patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 5) +@patch( + "homeassistant.util.logging.HomeAssistantQueueListener.EXCLUDED_LOG_COUNT_MODULES", + ["excluded"], +) @pytest.mark.parametrize( ( "logger1_count", @@ -182,6 +186,7 @@ async def test_noisy_loggers( logging_util.async_activate_log_queue_handler(hass) logger1 = logging.getLogger("noisy1") logger2 = logging.getLogger("noisy2.module") + logger_excluded = logging.getLogger("excluded.module") for _ in range(logger1_count): logger1.info("This is a log") @@ -189,6 +194,9 @@ async def test_noisy_loggers( for _ in range(logger2_count): logger2.info("This is another log") + for _ in range(logging_util.HomeAssistantQueueListener.MAX_LOGS_COUNT + 1): + logger_excluded.info("This log should not trigger a warning") + await empty_log_queue() assert ( @@ -203,6 +211,33 @@ async def test_noisy_loggers( ) == logger2_expected_notices ) + # Ensure that the excluded module did not trigger a warning + assert ( + caplog.text.count("is logging too frequently") + == logger1_expected_notices + logger2_expected_notices + ) + + # close the handler so the queue thread stops + logging.root.handlers[0].close() + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 1) +async def test_noisy_loggers_ignores_self( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that the noisy loggers warning does not trigger a warning for its own module.""" + + logging_util.async_activate_log_queue_handler(hass) + logger1 = logging.getLogger("noisy_module1") + logger2 = logging.getLogger("noisy_module2") + logger3 = logging.getLogger("noisy_module3") + + logger1.info("This is a log") + logger2.info("This is a log") + logger3.info("This is a log") + + await empty_log_queue() + assert caplog.text.count("logging too frequently") == 3 # close the handler so the queue thread stops logging.root.handlers[0].close()