diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 82009751763..2a667f83daa 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: translations @@ -190,7 +190,7 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -256,7 +256,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -330,14 +330,14 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -462,7 +462,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: translations @@ -502,7 +502,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Login to GitHub Container Registry - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e96de66ac76..aca149bf020 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -970,7 +970,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: pytest_buckets - name: Compile English translations @@ -1336,7 +1336,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1486,7 +1486,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1511,7 +1511,7 @@ jobs: timeout-minutes: 10 steps: - name: Download all coverage artifacts - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: pattern: test-results-* - name: Upload test results to Codecov diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index fd7ed1a38a9..17777f576de 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v1.2.4 + uses: actions/ai-inference@v1.2.8 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index eefc896bfcb..1aa51492c74 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v1.2.4 + uses: actions/ai-inference@v1.2.8 with: model: openai/gpt-4o-mini system-prompt: | diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 8d9fca093de..3f0c0d578a9 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -138,17 +138,17 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: requirements_diff @@ -187,22 +187,22 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: requirements_all_wheels diff --git a/CODEOWNERS b/CODEOWNERS index 5ef8479d4d3..84a07305d36 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1613,8 +1613,6 @@ build.json @home-assistant/supervisor /tests/components/tplink_omada/ @MarkGodwin /homeassistant/components/traccar/ @ludeeus /tests/components/traccar/ @ludeeus -/homeassistant/components/traccar_server/ @ludeeus -/tests/components/traccar_server/ @ludeeus /homeassistant/components/trace/ @home-assistant/core /tests/components/trace/ @home-assistant/core /homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 1c2e8b0dfab..429aad09edb 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -120,6 +120,9 @@ class AuthStore: new_user = models.User(**kwargs) + while new_user.id in self._users: + new_user = models.User(**kwargs) + self._users[new_user.id] = new_user if credentials is None: diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py index 287f54101c8..8df93c7b2c4 100644 --- a/homeassistant/components/airos/config_flow.py +++ b/homeassistant/components/airos/config_flow.py @@ -6,11 +6,11 @@ import logging from typing import Any from airos.exceptions import ( - ConnectionAuthenticationError, - ConnectionSetupError, - DataMissingError, - DeviceConnectionError, - KeyDataMissingError, + AirOSConnectionAuthenticationError, + AirOSConnectionSetupError, + AirOSDataMissingError, + AirOSDeviceConnectionError, + AirOSKeyDataMissingError, ) import voluptuous as vol @@ -59,13 +59,13 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): airos_data = await airos_device.status() except ( - ConnectionSetupError, - DeviceConnectionError, + AirOSConnectionSetupError, + AirOSDeviceConnectionError, ): errors["base"] = "cannot_connect" - except (ConnectionAuthenticationError, DataMissingError): + except (AirOSConnectionAuthenticationError, AirOSDataMissingError): errors["base"] = "invalid_auth" - except KeyDataMissingError: + except AirOSKeyDataMissingError: errors["base"] = "key_data_missing" except Exception: _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/airos/coordinator.py b/homeassistant/components/airos/coordinator.py index 3f0f1a12380..2fe675ee76a 100644 --- a/homeassistant/components/airos/coordinator.py +++ b/homeassistant/components/airos/coordinator.py @@ -6,10 +6,10 @@ import logging from airos.airos8 import AirOS, AirOSData from airos.exceptions import ( - ConnectionAuthenticationError, - ConnectionSetupError, - DataMissingError, - DeviceConnectionError, + AirOSConnectionAuthenticationError, + AirOSConnectionSetupError, + AirOSDataMissingError, + AirOSDeviceConnectionError, ) from homeassistant.config_entries import ConfigEntry @@ -47,18 +47,22 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]): try: await self.airos_device.login() return await self.airos_device.status() - except (ConnectionAuthenticationError,) as err: + except (AirOSConnectionAuthenticationError,) as err: _LOGGER.exception("Error authenticating with airOS device") raise ConfigEntryError( translation_domain=DOMAIN, translation_key="invalid_auth" ) from err - except (ConnectionSetupError, DeviceConnectionError, TimeoutError) as err: + except ( + AirOSConnectionSetupError, + AirOSDeviceConnectionError, + TimeoutError, + ) as err: _LOGGER.error("Error connecting to airOS device: %s", err) raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", ) from err - except (DataMissingError,) as err: + except (AirOSDataMissingError,) as err: _LOGGER.error("Expected data not returned by airOS device: %s", err) raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index cb6119a6fa9..758902bbaa2 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airos", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["airos==0.2.1"] + "requirements": ["airos==0.2.4"] } diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py index 690bf21fc8e..4567261ba4d 100644 --- a/homeassistant/components/airos/sensor.py +++ b/homeassistant/components/airos/sensor.py @@ -69,13 +69,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( translation_key="wireless_essid", value_fn=lambda data: data.wireless.essid, ), - AirOSSensorEntityDescription( - key="wireless_mode", - translation_key="wireless_mode", - device_class=SensorDeviceClass.ENUM, - value_fn=lambda data: data.wireless.mode.value.replace("-", "_").lower(), - options=WIRELESS_MODE_OPTIONS, - ), AirOSSensorEntityDescription( key="wireless_antenna_gain", translation_key="wireless_antenna_gain", diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index 6823ba8520b..ff013862ee5 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -43,13 +43,6 @@ "wireless_essid": { "name": "Wireless SSID" }, - "wireless_mode": { - "name": "Wireless mode", - "state": { - "ap_ptp": "Access point", - "sta_ptp": "Station" - } - }, "wireless_antenna_gain": { "name": "Antenna gain" }, diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index bdb9aa3186c..490ef3dc2dc 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -9,7 +9,6 @@ DOMAIN: Final = "amberelectric" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_CHANNEL_TYPE = "channel_type" ATTRIBUTION = "Data provided by Amber Electric" diff --git a/homeassistant/components/amberelectric/services.py b/homeassistant/components/amberelectric/services.py index 074a2f0ac88..c22a04f2845 100644 --- a/homeassistant/components/amberelectric/services.py +++ b/homeassistant/components/amberelectric/services.py @@ -4,6 +4,7 @@ from amberelectric.models.channel import ChannelType import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -16,7 +17,6 @@ from homeassistant.util.json import JsonValueType from .const import ( ATTR_CHANNEL_TYPE, - ATTR_CONFIG_ENTRY_ID, CONTROLLED_LOAD_CHANNEL, DOMAIN, FEED_IN_CHANNEL, diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index e143e4d47c2..b996b7d38c5 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -81,11 +81,15 @@ async def async_update_options( async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -99,30 +103,61 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if entry.data[CONF_API_KEY] not in api_keys_entries: use_existing = True - api_keys_entries[entry.data[CONF_API_KEY]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: - entity_registry.async_update_entity( - conversation_entity, - config_entry_id=parent_entry.entry_id, - config_subentry_id=subentry.subentry_id, - new_unique_id=subentry.subentry_id, - ) - device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -147,7 +182,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_CONVERSATION_NAME, options={}, version=2, - minor_version=2, + minor_version=3, ) @@ -173,6 +208,38 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 2 and entry.minor_version == 2: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 099eae73d31..0c555d19bd9 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -75,7 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" VERSION = 2 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index e7af7d84942..51c5225b894 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.2"] + "requirements": ["yalexs==8.11.1", "yalexs-ble==3.1.2"] } diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 9758af60178..1a125516130 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "requirements": ["axis==64"], + "requirements": ["axis==65"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 0f24eec2178..3e4ffeeea07 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -25,7 +25,6 @@ SERVICE_TRIGGER = "trigger_camera" SERVICE_SAVE_VIDEO = "save_video" SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" SERVICE_SEND_PIN = "send_pin" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 1f748bd9f63..2cb6a325724 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -5,12 +5,12 @@ from __future__ import annotations import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PIN +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_PIN from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_SEND_PIN +from .const import DOMAIN, SERVICE_SEND_PIN from .coordinator import BlinkConfigEntry SERVICE_SEND_PIN_SCHEMA = vol.Schema( diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index cd6aae91259..ce5d98f8edb 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", "dbus-fast==2.44.3", - "habluetooth==4.0.1" + "habluetooth==4.0.2" ] } diff --git a/homeassistant/components/bosch_alarm/const.py b/homeassistant/components/bosch_alarm/const.py index 33ec0ae526a..d6f651e8124 100644 --- a/homeassistant/components/bosch_alarm/const.py +++ b/homeassistant/components/bosch_alarm/const.py @@ -6,4 +6,3 @@ CONF_INSTALLER_CODE = "installer_code" CONF_USER_CODE = "user_code" ATTR_DATETIME = "datetime" SERVICE_SET_DATE_TIME = "set_date_time" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" diff --git a/homeassistant/components/bosch_alarm/services.py b/homeassistant/components/bosch_alarm/services.py index acdecbda305..f3292f97ee8 100644 --- a/homeassistant/components/bosch_alarm/services.py +++ b/homeassistant/components/bosch_alarm/services.py @@ -9,12 +9,13 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.util import dt as dt_util -from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME +from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME from .types import BoschAlarmConfigEntry diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index d0e0bd0b1d0..3b201c79e0c 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.6.0", "icalendar==6.1.0"] + "requirements": ["caldav==1.6.0", "icalendar==6.3.1"] } diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index fb5ba4f1796..8ef1b984ff9 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -100,16 +100,10 @@ set_hvac_mode: fields: hvac_mode: selector: - select: - options: - - "off" - - "auto" - - "cool" - - "dry" - - "fan_only" - - "heat_cool" - - "heat" - translation_key: hvac_mode + state: + hide_states: + - unavailable + - unknown set_swing_mode: target: entity: diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 63eae6261d4..76e55bc19b3 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.110.1"], + "requirements": ["hass-nabucasa==0.111.1"], "single_config_entry": true } diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 5e2146a533c..63be9641aeb 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -61,7 +61,7 @@ class DeviceCondition(Condition): self._hass = hass @classmethod - async def async_validate_condition_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate device condition config.""" @@ -69,7 +69,7 @@ class DeviceCondition(Condition): hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION ) - async def async_condition_from_config(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> condition.ConditionCheckerType: """Test a device condition.""" platform = await async_get_device_automation_platform( self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION @@ -80,7 +80,7 @@ class DeviceCondition(Condition): CONDITIONS: dict[str, type[Condition]] = { - "device": DeviceCondition, + "_device": DeviceCondition, } diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index eb844ad8d3f..8b33c1d7ed3 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -18,6 +18,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If path is relative, we assume relative to Home Assistant config dir if not os.path.isabs(download_path): download_path = hass.config.path(download_path) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DOWNLOAD_DIR: download_path} + ) if not await hass.async_add_executor_job(os.path.isdir, download_path): _LOGGER.error( diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py index bb1b968dd99..0ccaee232d7 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -11,6 +11,7 @@ import requests import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path @@ -34,24 +35,33 @@ def download_file(service: ServiceCall) -> None: entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0] download_path = entry.data[CONF_DOWNLOAD_DIR] + url: str = service.data[ATTR_URL] + subdir: str | None = service.data.get(ATTR_SUBDIR) + target_filename: str | None = service.data.get(ATTR_FILENAME) + overwrite: bool = service.data[ATTR_OVERWRITE] + + if subdir: + # Check the path + try: + raise_if_invalid_path(subdir) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="subdir_invalid", + translation_placeholders={"subdir": subdir}, + ) from err + if os.path.isabs(subdir): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="subdir_not_relative", + translation_placeholders={"subdir": subdir}, + ) def do_download() -> None: """Download the file.""" + final_path = None + filename = target_filename try: - url = service.data[ATTR_URL] - - subdir = service.data.get(ATTR_SUBDIR) - - filename = service.data.get(ATTR_FILENAME) - - overwrite = service.data.get(ATTR_OVERWRITE) - - if subdir: - # Check the path - raise_if_invalid_path(subdir) - - final_path = None - req = requests.get(url, stream=True, timeout=10) if req.status_code != HTTPStatus.OK: diff --git a/homeassistant/components/downloader/strings.json b/homeassistant/components/downloader/strings.json index 7db7ea459d7..98c4a0a6c82 100644 --- a/homeassistant/components/downloader/strings.json +++ b/homeassistant/components/downloader/strings.json @@ -12,6 +12,14 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, + "exceptions": { + "subdir_invalid": { + "message": "Invalid subdirectory, got: {subdir}" + }, + "subdir_not_relative": { + "message": "Subdirectory must be relative, got: {subdir}" + } + }, "services": { "download_file": { "name": "Download file", diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index 115c91eceeb..cbb3a230c90 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -20,7 +20,6 @@ from homeassistant.const import Platform _LOGGER = logging.getLogger(__package__) DOMAIN = "ecobee" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_AVAILABLE_SENSORS = "available_sensors" ATTR_ACTIVE_SENSORS = "active_sensors" diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index ceb7a1da9de..ddd464bdc6a 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.11", "deebot-client==13.5.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.6.0"] } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 706940f5da7..61ca88ba70a 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==20250731.0"] + "requirements": ["home-assistant-frontend==20250806.0"] } diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 11e7c75c8ba..545436da590 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -123,10 +123,10 @@ }, "ai_task_data": { "initiate_flow": { - "user": "Add Generate data with AI service", - "reconfigure": "Reconfigure Generate data with AI service" + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "step": { "set_options": { "data": { diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 16697659077..35f7f48481e 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -86,9 +86,11 @@ UNSUPPORTED_REASONS = { UNSUPPORTED_SKIP_REPAIR = {"privileged"} UNHEALTHY_REASONS = { "docker", - "supervisor", - "setup", + "duplicate_os_installation", + "oserror_bad_message", "privileged", + "setup", + "supervisor", "untrusted", } diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 1e312ee34d9..5df197bddcb 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -117,35 +117,43 @@ }, "unhealthy": { "title": "Unhealthy system - {reason}", - "description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more." }, "unhealthy_docker": { "title": "Unhealthy system - Docker misconfigured", - "description": "System is currently unhealthy because Docker is configured incorrectly. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy because Docker is configured incorrectly. For troubleshooting information, select Learn more." }, - "unhealthy_supervisor": { - "title": "Unhealthy system - Supervisor update failed", - "description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. Use the link to learn more and how to fix this." + "unhealthy_duplicate_os_installation": { + "description": "System is currently unhealthy because it has detected multiple Home Assistant OS installations. For troubleshooting information, select Learn more.", + "title": "Unhealthy system - Duplicate Home Assistant OS installation" }, - "unhealthy_setup": { - "title": "Unhealthy system - Setup failed", - "description": "System is currently unhealthy because setup failed to complete. There are a number of reasons this can occur, use the link to learn more and how to fix this." + "unhealthy_oserror_bad_message": { + "description": "System is currently unhealthy because the operating system has reported an OS error: Bad message. For troubleshooting information, select Learn more.", + "title": "Unhealthy system - Operating System error: Bad message" }, "unhealthy_privileged": { "title": "Unhealthy system - Not privileged", - "description": "System is currently unhealthy because it does not have privileged access to the docker runtime. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy because it does not have privileged access to the docker runtime. For troubleshooting information, select Learn more." + }, + "unhealthy_setup": { + "title": "Unhealthy system - Setup failed", + "description": "System is currently unhealthy because setup failed to complete. There are a number of reasons this can occur, For troubleshooting information, select Learn more." + }, + "unhealthy_supervisor": { + "title": "Unhealthy system - Supervisor update failed", + "description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. For troubleshooting information, select Learn more." }, "unhealthy_untrusted": { "title": "Unhealthy system - Untrusted code", - "description": "System is currently unhealthy because it has detected untrusted code or images in use. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy because it has detected untrusted code or images in use. For troubleshooting information, select Learn more." }, "unsupported": { "title": "Unsupported system - {reason}", - "description": "System is unsupported due to {reason}. Use the link to learn more and how to fix this." + "description": "System is unsupported due to {reason}. For troubleshooting information, select Learn more." }, "unsupported_apparmor": { "title": "Unsupported system - AppArmor issues", - "description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. Use the link to learn more and how to fix this." + "description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. For troubleshooting information, select Learn more." }, "unsupported_cgroup_version": { "title": "Unsupported system - CGroup version", @@ -153,23 +161,23 @@ }, "unsupported_connectivity_check": { "title": "Unsupported system - Connectivity check disabled", - "description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. Use the link to learn more and how to fix this." + "description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more." }, "unsupported_content_trust": { "title": "Unsupported system - Content-trust check disabled", - "description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. Use the link to learn more and how to fix this." + "description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more." }, "unsupported_dbus": { "title": "Unsupported system - D-Bus issues", - "description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. Use the link to learn more and how to fix this." + "description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more." }, "unsupported_dns_server": { "title": "Unsupported system - DNS server issues", - "description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. Use the link to learn more and how to fix this." + "description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. For troubleshooting information, select Learn more." }, "unsupported_docker_configuration": { "title": "Unsupported system - Docker misconfigured", - "description": "System is unsupported because the Docker daemon is running in an unexpected way. Use the link to learn more and how to fix this." + "description": "System is unsupported because the Docker daemon is running in an unexpected way. For troubleshooting information, select Learn more." }, "unsupported_docker_version": { "title": "Unsupported system - Docker version", @@ -177,15 +185,15 @@ }, "unsupported_job_conditions": { "title": "Unsupported system - Protections disabled", - "description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. Use the link to learn more and how to fix this." + "description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. For troubleshooting information, select Learn more." }, "unsupported_lxc": { "title": "Unsupported system - LXC detected", - "description": "System is unsupported because it is being run in an LXC virtual machine. Use the link to learn more and how to fix this." + "description": "System is unsupported because it is being run in an LXC virtual machine. For troubleshooting information, select Learn more." }, "unsupported_network_manager": { "title": "Unsupported system - Network Manager issues", - "description": "System is unsupported because Network Manager is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Network Manager is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_os": { "title": "Unsupported system - Operating System", @@ -193,39 +201,43 @@ }, "unsupported_os_agent": { "title": "Unsupported system - OS-Agent issues", - "description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_restart_policy": { "title": "Unsupported system - Container restart policy", - "description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. Use the link to learn more and how to fix this." + "description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. For troubleshooting information, select Learn more." }, "unsupported_software": { "title": "Unsupported system - Unsupported software", - "description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. Use the link to learn more and how to fix this." + "description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more." }, "unsupported_source_mods": { "title": "Unsupported system - Supervisor source modifications", - "description": "System is unsupported because Supervisor source code has been modified. Use the link to learn more and how to fix this." + "description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more." }, "unsupported_supervisor_version": { "title": "Unsupported system - Supervisor version", - "description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. Use the link to learn more and how to fix this." + "description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more." }, "unsupported_systemd": { "title": "Unsupported system - Systemd issues", - "description": "System is unsupported because Systemd is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_systemd_journal": { "title": "Unsupported system - Systemd Journal issues", - "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_systemd_resolved": { "title": "Unsupported system - Systemd-Resolved issues", - "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_virtualization_image": { "title": "Unsupported system - Incorrect OS image for virtualization", - "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this." + "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. For troubleshooting information, select Learn more." + }, + "unsupported_os_version": { + "title": "Unsupported system - Home Assistant OS version", + "description": "System is unsupported because the Home Assistant OS version in use is not supported. For troubleshooting information, select Learn more." } }, "entity": { diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 05cdd2738b6..dde50da1af3 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.77", "babel==2.15.0"] + "requirements": ["holidays==0.78", "babel==2.15.0"] } diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 56b7c5023f5..a7bd90baefd 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -24,6 +24,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, ATTR_HW_VERSION, ATTR_MODEL, ATTR_SW_VERSION, @@ -54,7 +55,6 @@ from homeassistant.helpers.typing import ConfigType from .const import ( ADMIN_SERVICES, ALL_KEYS, - ATTR_CONFIG_ENTRY_ID, CONF_MANUFACTURER, CONF_UNAUTHENTICATED_MODE, CONF_UPNP_UDN, diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index b7662200767..bc114f56e99 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -2,8 +2,6 @@ DOMAIN = "huawei_lte" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" - CONF_MANUFACTURER = "manufacturer" CONF_TRACK_WIRED_CLIENTS = "track_wired_clients" CONF_UNAUTHENTICATED_MODE = "unauthenticated_mode" diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 682470bafd0..7543eb71d88 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -8,12 +8,12 @@ from typing import Any from huawei_lte_api.exceptions import ResponseErrorException from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import CONF_RECIPIENT +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_RECIPIENT from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import Router -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 8e58a309e59..b39f2138ab4 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -21,6 +21,20 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 +async def async_reset_cutting_blade_usage_time( + session: AutomowerSession, + mower_id: str, +) -> None: + """Reset cutting blade usage time.""" + await session.commands.reset_cutting_blade_usage_time(mower_id) + + +def reset_cutting_blade_usage_time_availability(data: MowerAttributes) -> bool: + """Return True if blade usage time is greater than 0.""" + value = data.statistics.cutting_blade_usage_time + return value is not None and value > 0 + + @dataclass(frozen=True, kw_only=True) class AutomowerButtonEntityDescription(ButtonEntityDescription): """Describes Automower button entities.""" @@ -28,6 +42,7 @@ class AutomowerButtonEntityDescription(ButtonEntityDescription): available_fn: Callable[[MowerAttributes], bool] = lambda _: True exists_fn: Callable[[MowerAttributes], bool] = lambda _: True press_fn: Callable[[AutomowerSession, str], Awaitable[Any]] + poll_after_sending: bool = False MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( @@ -43,6 +58,14 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( translation_key="sync_clock", press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id), ), + AutomowerButtonEntityDescription( + key="reset_cutting_blade_usage_time", + translation_key="reset_cutting_blade_usage_time", + available_fn=reset_cutting_blade_usage_time_availability, + exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None, + press_fn=async_reset_cutting_blade_usage_time, + poll_after_sending=True, + ), ) @@ -93,3 +116,5 @@ class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity): async def async_press(self) -> None: """Send a command to the mower.""" await self.entity_description.press_fn(self.coordinator.api, self.mower_id) + if self.entity_description.poll_after_sending: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 91adc8c75ec..a037df474cc 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import override @@ -14,7 +14,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import MowerDictionary +from aioautomower.model import MowerDictionary, MowerStates from aioautomower.session import AutomowerSession from homeassistant.config_entries import ConfigEntry @@ -29,7 +29,9 @@ _LOGGER = logging.getLogger(__name__) MAX_WS_RECONNECT_TIME = 600 SCAN_INTERVAL = timedelta(minutes=8) DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time - +PONG_TIMEOUT = timedelta(seconds=90) +PING_INTERVAL = timedelta(seconds=10) +PING_TIMEOUT = timedelta(seconds=5) type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator] @@ -58,6 +60,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] + self.pong: datetime | None = None + self.websocket_alive: bool = False + self._watchdog_task: asyncio.Task | None = None @override @callback @@ -71,6 +76,18 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): await self.api.connect() self.api.register_data_callback(self.handle_websocket_updates) self.ws_connected = True + + def start_watchdog() -> None: + if self._watchdog_task is not None and not self._watchdog_task.done(): + _LOGGER.debug("Cancelling previous watchdog task") + self._watchdog_task.cancel() + self._watchdog_task = self.config_entry.async_create_background_task( + self.hass, + self._pong_watchdog(), + "websocket_watchdog", + ) + + self.api.register_ws_ready_callback(start_watchdog) try: data = await self.api.get_status() except ApiError as err: @@ -93,6 +110,19 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): mower_data.capabilities.work_areas for mower_data in self.data.values() ): self._async_add_remove_work_areas() + if ( + not self._should_poll() + and self.update_interval is not None + and self.websocket_alive + ): + _LOGGER.debug("All mowers inactive and websocket alive: stop polling") + self.update_interval = None + if self.update_interval is None and self._should_poll(): + _LOGGER.debug( + "Polling re-enabled via WebSocket: at least one mower active" + ) + self.update_interval = SCAN_INTERVAL + self.hass.async_create_task(self.async_request_refresh()) @callback def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: @@ -161,6 +191,30 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): "reconnect_task", ) + def _should_poll(self) -> bool: + """Return True if at least one mower is connected and at least one is not OFF.""" + return any(mower.metadata.connected for mower in self.data.values()) and any( + mower.mower.state != MowerStates.OFF for mower in self.data.values() + ) + + async def _pong_watchdog(self) -> None: + _LOGGER.debug("Watchdog started") + try: + while True: + _LOGGER.debug("Sending ping") + self.websocket_alive = await self.api.send_empty_message() + _LOGGER.debug("Ping result: %s", self.websocket_alive) + + await asyncio.sleep(60) + _LOGGER.debug("Websocket alive %s", self.websocket_alive) + if not self.websocket_alive: + _LOGGER.debug("No pong received → restart polling") + if self.update_interval is None: + self.update_interval = SCAN_INTERVAL + await self.async_request_refresh() + except asyncio.CancelledError: + _LOGGER.debug("Watchdog cancelled") + def _async_add_remove_devices(self) -> None: """Add new devices and remove orphaned devices from the registry.""" current_devices = set(self.data) diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index e9d023bd3cc..5ff5940bdf4 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -8,6 +8,9 @@ "button": { "sync_clock": { "default": "mdi:clock-check-outline" + }, + "reset_cutting_blade_usage_time": { + "default": "mdi:saw-blade" } }, "number": { diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index a0f25b1df4c..49eb364858f 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==2.1.1"] + "requirements": ["aioautomower==2.1.2"] } diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 226c9ee17f0..bd8a9346552 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -53,6 +53,9 @@ }, "sync_clock": { "name": "Sync clock" + }, + "reset_cutting_blade_usage_time": { + "name": "Reset cutting blade usage time" } }, "number": { diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 9c73c4d970f..721c462c800 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -42,10 +42,19 @@ "local_name": "Ink@IAM-T1", "connectable": true }, + { + "local_name": "Ink@IAM-T2", + "connectable": true + }, { "manufacturer_id": 12628, "manufacturer_data_start": [65, 67, 45], "connectable": true + }, + { + "manufacturer_id": 12884, + "manufacturer_data_start": [0, 98, 0], + "connectable": false } ], "codeowners": ["@bdraco"], @@ -53,5 +62,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.16.2"] + "requirements": ["inkbird-ble==1.1.0"] } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 6a4565dde0e..f3013de4556 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.7.23.50952" + "knx-frontend==2025.8.6.52906" ], "single_config_entry": true } diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index 6ee6a309cac..1397775b351 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["letpot"], "quality_scale": "bronze", - "requirements": ["letpot==0.5.0"] + "requirements": ["letpot==0.6.1"] } diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index 2efda329467..8a77eebcf7a 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -12,7 +12,6 @@ DATA_HASS_CONFIG = "mastodon_hass_config" DEFAULT_URL: Final = "https://mastodon.social" DEFAULT_NAME: Final = "Mastodon" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_STATUS = "status" ATTR_VISIBILITY = "visibility" ATTR_CONTENT_WARNING = "content_warning" diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 68e95e726a1..0815fee34ec 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -9,11 +9,11 @@ from mastodon.Mastodon import MastodonAPIError, MediaAttachment import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_CONTENT_WARNING, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 2b9ca2cc3e2..475504d5aeb 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -99,6 +99,9 @@ "esa_opt_out_state": { "default": "mdi:home-lightning-bolt" }, + "esa_state": { + "default": "mdi:home-lightning-bolt" + }, "evse_state": { "default": "mdi:ev-station" }, diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 4456496d52e..d2184891dc1 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -285,7 +285,9 @@ DISCOVERY_SCHEMAS = [ native_min_value=0.5, native_step=0.5, device_to_ha=( - lambda x: None if x is None else x / 2 # Matter range (1-200) + lambda x: None + if x is None + else min(x, 200) / 2 # Matter range (1-200, capped at 200) ), ha_to_device=lambda x: round(x * 2), # HA range 0.5–100.0% mode=NumberMode.SLIDER, diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 6ab687e060a..cf9f26adecb 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -140,11 +140,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() - # optional battery level - if VacuumEntityFeature.BATTERY & self._attr_supported_features: - self._attr_battery_level = self.get_matter_attribute_value( - clusters.PowerSource.Attributes.BatPercentRemaining - ) # derive state from the run mode + operational state run_mode_raw: int = self.get_matter_attribute_value( clusters.RvcRunMode.Attributes.CurrentMode @@ -188,11 +183,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): supported_features |= VacuumEntityFeature.STATE supported_features |= VacuumEntityFeature.STOP - # optional battery attribute = battery feature - if self.get_matter_attribute_value( - clusters.PowerSource.Attributes.BatPercentRemaining - ): - supported_features |= VacuumEntityFeature.BATTERY # optional identify cluster = locate feature (value must be not None or 0) if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType): supported_features |= VacuumEntityFeature.LOCATE @@ -230,7 +220,6 @@ DISCOVERY_SCHEMAS = [ clusters.RvcRunMode.Attributes.CurrentMode, clusters.RvcOperationalState.Attributes.OperationalState, ), - optional_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), device_type=(device_types.RoboticVacuumCleaner,), allow_none_value=True, ), diff --git a/homeassistant/components/mealie/const.py b/homeassistant/components/mealie/const.py index 481cc4ccb7d..e729265bcbc 100644 --- a/homeassistant/components/mealie/const.py +++ b/homeassistant/components/mealie/const.py @@ -8,7 +8,6 @@ DOMAIN = "mealie" LOGGER = logging.getLogger(__package__) -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_START_DATE = "start_date" ATTR_END_DATE = "end_date" ATTR_RECIPE_ID = "recipe_id" diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index f219cea1835..37b485e18f2 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -13,7 +13,7 @@ from aiomealie import ( import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DATE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -25,7 +25,6 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_END_DATE, ATTR_ENTRY_TYPE, ATTR_INCLUDE_TAGS, diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index e31af281783..c701af2865c 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -130,6 +130,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): list_id=self._shopping_list_id, note=item.summary.strip() if item.summary else item.summary, position=position, + quantity=0.0, ) try: await self.coordinator.client.add_shopping_item(new_shopping_item) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 25c35b3e87e..1dab894b2f6 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -25,7 +25,6 @@ ATTR_APP_DATA = "app_data" ATTR_APP_ID = "app_id" ATTR_APP_NAME = "app_name" ATTR_APP_VERSION = "app_version" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_DEVICE_NAME = "device_name" ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL = "model" diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 555026b4bda..656b69920a0 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], - "requirements": ["pymodbus==3.9.2"] + "requirements": ["pymodbus==3.11.0"] } diff --git a/homeassistant/components/mqtt/icons.json b/homeassistant/components/mqtt/icons.json index 46a588a5667..1aa0902b77e 100644 --- a/homeassistant/components/mqtt/icons.json +++ b/homeassistant/components/mqtt/icons.json @@ -11,7 +11,7 @@ } }, "triggers": { - "mqtt": { + "_": { "trigger": "mdi:swap-horizontal" } } diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 0e248cfd2d2..77a476bf40c 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,5 +1,9 @@ { "issues": { + "deprecated_vacuum_battery_feature": { + "title": "Deprecated battery feature used", + "description": "Vacuum entity {entity_id} implements the battery feature which is deprecated. This will stop working in Home Assistant 2026.2. Implement a separate entity for the battery state instead. To fix the issue, remove the `battery` feature from the configured supported features, and restart Home Assistant." + }, "invalid_platform_config": { "title": "Invalid config found for MQTT {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." @@ -1285,7 +1289,7 @@ } }, "triggers": { - "mqtt": { + "_": { "name": "MQTT", "description": "When a specific message is received on a given MQTT topic.", "description_configured": "When an MQTT message has been received", diff --git a/homeassistant/components/mqtt/triggers.yaml b/homeassistant/components/mqtt/triggers.yaml index d3998674d58..0de44f4b39f 100644 --- a/homeassistant/components/mqtt/triggers.yaml +++ b/homeassistant/components/mqtt/triggers.yaml @@ -1,6 +1,6 @@ # Describes the format for MQTT triggers -mqtt: +_: fields: payload: example: "on" diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index f1d2eb34fe1..28cc883fa9e 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -17,7 +17,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback -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 AddConfigEntryEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType @@ -25,11 +25,11 @@ from homeassistant.util.json import json_loads_object from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC -from .entity import MqttEntity, async_setup_entity_entry_helper +from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, DOMAIN +from .entity import IssueSeverity, MqttEntity, async_setup_entity_entry_helper from .models import ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA -from .util import valid_publish_topic +from .util import learn_more_url, valid_publish_topic PARALLEL_UPDATES = 0 @@ -84,6 +84,8 @@ SERVICE_TO_STRING: dict[VacuumEntityFeature, str] = { VacuumEntityFeature.STOP: "stop", VacuumEntityFeature.RETURN_HOME: "return_home", VacuumEntityFeature.FAN_SPEED: "fan_speed", + # Use of the battery feature was deprecated in HA Core 2025.8 + # and will be removed with HA Core 2026.2 VacuumEntityFeature.BATTERY: "battery", VacuumEntityFeature.STATUS: "status", VacuumEntityFeature.SEND_COMMAND: "send_command", @@ -96,7 +98,6 @@ DEFAULT_SERVICES = ( VacuumEntityFeature.START | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.CLEAN_SPOT ) ALL_SERVICES = ( @@ -251,10 +252,35 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): ) } + async def mqtt_async_added_to_hass(self) -> None: + """Check for use of deprecated battery features.""" + if self.supported_features & VacuumEntityFeature.BATTERY: + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_vacuum_battery_feature_{self.entity_id}", + issue_domain=vacuum.DOMAIN, + breaks_in_ha_version="2026.2", + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url=learn_more_url(vacuum.DOMAIN), + translation_placeholders={"entity_id": self.entity_id}, + translation_key="deprecated_vacuum_battery_feature", + ) + _LOGGER.warning( + "MQTT vacuum entity %s implements the battery feature " + "which is deprecated. This will stop working " + "in Home Assistant 2026.2. Implement a separate entity " + "for the battery status instead", + self.entity_id, + ) + def _update_state_attributes(self, payload: dict[str, Any]) -> None: """Update the entity state attributes.""" self._state_attrs.update(payload) self._attr_fan_speed = self._state_attrs.get(FAN_SPEED, 0) + # Use of the battery feature was deprecated in HA Core 2025.8 + # and will be removed with HA Core 2026.2 self._attr_battery_level = max(0, min(100, self._state_attrs.get(BATTERY, 0))) @callback diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index 031229d1544..a0e82ba3315 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -8,6 +8,7 @@ from music_assistant_models.enums import MediaType import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -24,7 +25,6 @@ from .const import ( ATTR_ALBUMS, ATTR_ARTISTS, ATTR_AUDIOBOOKS, - ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_ITEMS, ATTR_LIBRARY_ONLY, diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index d2ee1f75028..8c1701b4afd 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -26,7 +26,6 @@ ATTR_OFFSET = "offset" ATTR_ORDER_BY = "order_by" ATTR_ALBUM_TYPE = "album_type" ATTR_ALBUM_ARTISTS_ONLY = "album_artists_only" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_URI = "uri" ATTR_IMAGE = "image" ATTR_VERSION = "version" diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index e16550c1e94..091e58dbe7f 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -92,11 +92,15 @@ async def async_update_options(hass: HomeAssistant, entry: OllamaConfigEntry) -> async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + url_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -112,33 +116,64 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=entry.title, unique_id=None, ) - if entry.data[CONF_URL] not in api_keys_entries: + if entry.data[CONF_URL] not in url_entries: use_existing = True - api_keys_entries[entry.data[CONF_URL]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_URL] == entry.data[CONF_URL] + ) + url_entries[entry.data[CONF_URL]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_URL]] + parent_entry, all_disabled = url_entries[entry.data[CONF_URL]] hass.config_entries.async_add_subentry(parent_entry, subentry) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: - entity_registry.async_update_entity( - conversation_entity, - config_entry_id=parent_entry.entry_id, - config_subentry_id=subentry.subentry_id, - new_unique_id=subentry.subentry_id, - ) - device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -158,6 +193,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry( entry, title=DEFAULT_NAME, @@ -165,7 +201,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: data={CONF_URL: entry.data[CONF_URL]}, options={}, version=3, - minor_version=1, + minor_version=3, ) @@ -211,32 +247,69 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> ) if entry.version == 3 and entry.minor_version == 1: - # Add AI Task subentry with default options. We can only create a new - # subentry if we can find an existing model in the entry. The model - # was removed in the previous migration step, so we need to - # check the subentries for an existing model. - existing_model = next( - iter( - model - for subentry in entry.subentries.values() - if (model := subentry.data.get(CONF_MODEL)) is not None - ), - None, - ) - if existing_model: - hass.config_entries.async_add_subentry( - entry, - ConfigSubentry( - data=MappingProxyType({CONF_MODEL: existing_model}), - subentry_type="ai_task_data", - title=DEFAULT_AI_TASK_NAME, - unique_id=None, - ), - ) + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 3 and entry.minor_version == 2: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + _LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) return True + + +def _add_ai_task_subentry(hass: HomeAssistant, entry: OllamaConfigEntry) -> None: + """Add AI Task subentry to the config entry.""" + # Add AI Task subentry with default options. We can only create a new + # subentry if we can find an existing model in the entry. The model + # was removed in the previous migration step, so we need to + # check the subentries for an existing model. + existing_model = next( + iter( + model + for subentry in entry.subentries.values() + if (model := subentry.data.get(CONF_MODEL)) is not None + ), + None, + ) + if existing_model: + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType({CONF_MODEL: existing_model}), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index cca917f6c29..68deb00d205 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -76,7 +76,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" VERSION = 3 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def __init__(self) -> None: """Initialize config flow.""" diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index 4f3cb3c30c0..9ec03cef69a 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -58,10 +58,10 @@ }, "ai_task_data": { "initiate_flow": { - "user": "Add Generate data with AI service", - "reconfigure": "Reconfigure Generate data with AI service" + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "step": { "set_options": { "data": { diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index 64c7a8c3ebb..c66ec3bef15 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -53,8 +53,6 @@ class OneWireEntity(Entity): """Return the state attributes of the entity.""" return { "device_file": self._device_file, - # raw_value attribute is deprecated and can be removed in 2025.8 - "raw_value": self._value_raw, } def _read_value(self) -> str: diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json index e73a65cd178..43a27a91959 100644 --- a/homeassistant/components/open_router/strings.json +++ b/homeassistant/components/open_router/strings.json @@ -52,9 +52,9 @@ } }, "initiate_flow": { - "user": "Add Generate data with AI service" + "user": "Add AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 77b71ae372d..f50563b59ea 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -272,11 +272,15 @@ async def async_update_options(hass: HomeAssistant, entry: OpenAIConfigEntry) -> async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -290,30 +294,61 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if entry.data[CONF_API_KEY] not in api_keys_entries: use_existing = True - api_keys_entries[entry.data[CONF_API_KEY]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: - entity_registry.async_update_entity( - conversation_entity, - config_entry_id=parent_entry.entry_id, - config_subentry_id=subentry.subentry_id, - new_unique_id=subentry.subentry_id, - ) - device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -333,12 +368,13 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry( entry, title=DEFAULT_NAME, options={}, version=2, - minor_version=2, + minor_version=4, ) @@ -365,19 +401,56 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> hass.config_entries.async_update_entry(entry, minor_version=2) if entry.version == 2 and entry.minor_version == 2: - hass.config_entries.async_add_subentry( - entry, - ConfigSubentry( - data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), - subentry_type="ai_task_data", - title=DEFAULT_AI_TASK_NAME, - unique_id=None, - ), - ) + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=3) + if entry.version == 2 and entry.minor_version == 3: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=4) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) return True + + +def _add_ai_task_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None: + """Add AI Task subentry to the config entry.""" + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index aa1c967ca8f..c45c2b997b3 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -98,7 +98,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" VERSION = 2 - MINOR_VERSION = 3 + MINOR_VERSION = 4 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 4446eff2c9e..a1bf236f19b 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -73,10 +73,10 @@ }, "ai_task_data": { "initiate_flow": { - "user": "Add Generate data with AI service", - "reconfigure": "Reconfigure Generate data with AI service" + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "step": { "init": { "data": { diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index e7f2534e1ad..b66c4c6870e 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -9,6 +9,8 @@ from typing import Any from opower import ( CannotConnect, InvalidAuth, + MfaChallenge, + MfaHandlerBase, Opower, create_cookie_jar, get_supported_utility_names, @@ -16,49 +18,34 @@ from opower import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.typing import VolDictType -from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN +from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names()), - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) +CONF_MFA_CODE = "mfa_code" +CONF_MFA_METHOD = "mfa_method" async def _validate_login( - hass: HomeAssistant, login_data: dict[str, str] -) -> dict[str, str]: - """Validate login data and return any errors.""" + hass: HomeAssistant, + data: Mapping[str, Any], +) -> None: + """Validate login data and raise exceptions on failure.""" api = Opower( async_create_clientsession(hass, cookie_jar=create_cookie_jar()), - login_data[CONF_UTILITY], - login_data[CONF_USERNAME], - login_data[CONF_PASSWORD], - login_data.get(CONF_TOTP_SECRET), + data[CONF_UTILITY], + data[CONF_USERNAME], + data[CONF_PASSWORD], + data.get(CONF_TOTP_SECRET), + data.get(CONF_LOGIN_DATA), ) - errors: dict[str, str] = {} - try: - await api.async_login() - except InvalidAuth: - _LOGGER.exception( - "Invalid auth when connecting to %s", login_data[CONF_UTILITY] - ) - errors["base"] = "invalid_auth" - except CannotConnect: - _LOGGER.exception("Could not connect to %s", login_data[CONF_UTILITY]) - errors["base"] = "cannot_connect" - return errors + await api.async_login() class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): @@ -68,81 +55,147 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize a new OpowerConfigFlow.""" - self.utility_info: dict[str, Any] | None = None + self._data: dict[str, Any] = {} + self.mfa_handler: MfaHandlerBase | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} + """Handle the initial step (select utility).""" if user_input is not None: - self._async_abort_entries_match( - { - CONF_UTILITY: user_input[CONF_UTILITY], - CONF_USERNAME: user_input[CONF_USERNAME], - } - ) - if select_utility(user_input[CONF_UTILITY]).accepts_mfa(): - self.utility_info = user_input - return await self.async_step_mfa() + self._data[CONF_UTILITY] = user_input[CONF_UTILITY] + return await self.async_step_credentials() - errors = await _validate_login(self.hass, user_input) - if not errors: - return self._async_create_opower_entry(user_input) - else: - user_input = {} - user_input.pop(CONF_PASSWORD, None) return self.async_show_form( step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names())} + ), + ) + + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle credentials step.""" + errors: dict[str, str] = {} + utility = select_utility(self._data[CONF_UTILITY]) + + if user_input is not None: + self._data.update(user_input) + + self._async_abort_entries_match( + { + CONF_UTILITY: self._data[CONF_UTILITY], + CONF_USERNAME: self._data[CONF_USERNAME], + } + ) + + try: + await _validate_login(self.hass, self._data) + except MfaChallenge as exc: + self.mfa_handler = exc.handler + return await self.async_step_mfa_options() + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return self._async_create_opower_entry(self._data) + + schema_dict: VolDictType = { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + if utility.accepts_totp_secret(): + schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str + + return self.async_show_form( + step_id="credentials", data_schema=self.add_suggested_values_to_schema( - STEP_USER_DATA_SCHEMA, user_input + vol.Schema(schema_dict), user_input ), errors=errors, ) - async def async_step_mfa( + async def async_step_mfa_options( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle MFA step.""" - assert self.utility_info is not None + """Handle MFA options step.""" + errors: dict[str, str] = {} + assert self.mfa_handler is not None + + if user_input is not None: + method = user_input[CONF_MFA_METHOD] + try: + await self.mfa_handler.async_select_mfa_option(method) + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return await self.async_step_mfa_code() + + mfa_options = await self.mfa_handler.async_get_mfa_options() + if not mfa_options: + return await self.async_step_mfa_code() + return self.async_show_form( + step_id="mfa_options", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_MFA_METHOD): vol.In(mfa_options)}), + user_input, + ), + errors=errors, + ) + + async def async_step_mfa_code( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle MFA code submission step.""" + assert self.mfa_handler is not None errors: dict[str, str] = {} if user_input is not None: - data = {**self.utility_info, **user_input} - errors = await _validate_login(self.hass, data) - if not errors: - return self._async_create_opower_entry(data) - - if errors: - schema = { - vol.Required( - CONF_USERNAME, default=self.utility_info[CONF_USERNAME] - ): str, - vol.Required(CONF_PASSWORD): str, - } - else: - schema = {} - - schema[vol.Required(CONF_TOTP_SECRET)] = str + code = user_input[CONF_MFA_CODE] + try: + login_data = await self.mfa_handler.async_submit_mfa_code(code) + except InvalidAuth: + errors["base"] = "invalid_mfa_code" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + self._data[CONF_LOGIN_DATA] = login_data + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=self._data + ) + return self._async_create_opower_entry(self._data) return self.async_show_form( - step_id="mfa", - data_schema=vol.Schema(schema), + step_id="mfa_code", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_MFA_CODE): str}), user_input + ), errors=errors, ) @callback - def _async_create_opower_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + def _async_create_opower_entry( + self, data: dict[str, Any], **kwargs: Any + ) -> ConfigFlowResult: """Create the config entry.""" return self.async_create_entry( title=f"{data[CONF_UTILITY]} ({data[CONF_USERNAME]})", data=data, + **kwargs, ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - return await self.async_step_reauth_confirm() + reauth_entry = self._get_reauth_entry() + self._data = dict(reauth_entry.data) + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: reauth_entry.title}, + ) async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None @@ -150,21 +203,34 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): """Dialog that informs the user that reauth is required.""" errors: dict[str, str] = {} reauth_entry = self._get_reauth_entry() - if user_input is not None: - data = {**reauth_entry.data, **user_input} - errors = await _validate_login(self.hass, data) - if not errors: - return self.async_update_reload_and_abort(reauth_entry, data=data) - schema: VolDictType = { - vol.Required(CONF_USERNAME): reauth_entry.data[CONF_USERNAME], + if user_input is not None: + self._data.update(user_input) + try: + await _validate_login(self.hass, self._data) + except MfaChallenge as exc: + self.mfa_handler = exc.handler + return await self.async_step_mfa_options() + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort(reauth_entry, data=self._data) + + utility = select_utility(self._data[CONF_UTILITY]) + schema_dict: VolDictType = { + vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, } - if select_utility(reauth_entry.data[CONF_UTILITY]).accepts_mfa(): - schema[vol.Optional(CONF_TOTP_SECRET)] = str + if utility.accepts_totp_secret(): + schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str + return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema(schema), + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema_dict), self._data + ), errors=errors, description_placeholders={CONF_NAME: reauth_entry.title}, ) diff --git a/homeassistant/components/opower/const.py b/homeassistant/components/opower/const.py index c07d41bbdcf..5da50b2b06f 100644 --- a/homeassistant/components/opower/const.py +++ b/homeassistant/components/opower/const.py @@ -4,3 +4,4 @@ DOMAIN = "opower" CONF_UTILITY = "utility" CONF_TOTP_SECRET = "totp_secret" +CONF_LOGIN_DATA = "login_data" diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 189fa185cd1..e6fbbee0bb6 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -14,7 +14,7 @@ from opower import ( ReadResolution, create_cookie_jar, ) -from opower.exceptions import ApiException, CannotConnect, InvalidAuth +from opower.exceptions import ApiException, CannotConnect, InvalidAuth, MfaChallenge from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import ( @@ -36,7 +36,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN +from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -69,6 +69,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD], config_entry.data.get(CONF_TOTP_SECRET), + config_entry.data.get(CONF_LOGIN_DATA), ) @callback @@ -90,7 +91,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): # Given the infrequent updating (every 12h) # assume previous session has expired and re-login. await self.api.async_login() - except InvalidAuth as err: + except (InvalidAuth, MfaChallenge) as err: _LOGGER.error("Error during login: %s", err) raise ConfigEntryAuthFailed from err except CannotConnect as err: diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 4e88c5a68cc..a10c5b2d15d 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.12.4"] + "requirements": ["opower==0.15.1"] } diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 8d8cecff905..5bb22699220 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -3,27 +3,43 @@ "step": { "user": { "data": { - "utility": "Utility name", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "utility": "Utility name" }, "data_description": { - "utility": "The name of your utility provider", - "username": "The username for your utility account", - "password": "The password for your utility account" + "utility": "The name of your utility provider" } }, - "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.", + "credentials": { + "title": "Enter Credentials", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "totp_secret": "TOTP secret" }, "data_description": { - "username": "[%key:component::opower::config::step::user::data_description::username%]", - "password": "[%key:component::opower::config::step::user::data_description::password%]", - "totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)." + "username": "The username for your utility account", + "password": "The password for your utility account", + "totp_secret": "This is not a 6-digit code. 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." + } + }, + "mfa_options": { + "title": "Multi-factor authentication", + "description": "Your account requires multi-factor authentication (MFA). Select a method to receive your security code.", + "data": { + "mfa_method": "MFA method" + }, + "data_description": { + "mfa_method": "How to receive your security code" + } + }, + "mfa_code": { + "title": "Enter security code", + "description": "A security code has been sent via your selected method. Please enter it below to complete login.", + "data": { + "mfa_code": "Security code" + }, + "data_description": { + "mfa_code": "Typically a 6-digit code" } }, "reauth_confirm": { @@ -31,18 +47,19 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]" + "totp_secret": "[%key:component::opower::config::step::credentials::data::totp_secret%]" }, "data_description": { - "username": "[%key:component::opower::config::step::user::data_description::username%]", - "password": "[%key:component::opower::config::step::user::data_description::password%]", - "totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)." + "username": "[%key:component::opower::config::step::credentials::data_description::username%]", + "password": "[%key:component::opower::config::step::credentials::data_description::password%]", + "totp_secret": "[%key:component::opower::config::step::credentials::data_description::totp_secret%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_mfa_code": "The security code is incorrect. Please try again." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index 2aa0879ffed..da1fc051608 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -9,7 +9,6 @@ LOGGER = logging.getLogger(__package__) REQUESTS = "requests" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_STATUS = "status" ATTR_SORT_ORDER = "sort_order" ATTR_REQUESTED_BY = "requested_by" diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py index 4e72f555603..3c7335de15b 100644 --- a/homeassistant/components/overseerr/services.py +++ b/homeassistant/components/overseerr/services.py @@ -7,6 +7,7 @@ from python_overseerr import OverseerrClient, OverseerrConnectionError import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -17,14 +18,7 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util.json import JsonValueType -from .const import ( - ATTR_CONFIG_ENTRY_ID, - ATTR_REQUESTED_BY, - ATTR_SORT_ORDER, - ATTR_STATUS, - DOMAIN, - LOGGER, -) +from .const import ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, LOGGER from .coordinator import OverseerrConfigEntry SERVICE_GET_REQUESTS = "get_requests" diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 4e8eafd8912..f8737806746 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -9,7 +9,6 @@ CONF_COORDINATOR = "coordinator" SERVICE_ADD_PRODUCT_TO_CART = "add_product" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_PRODUCT_ID = "product_id" ATTR_PRODUCT_NAME = "product_name" ATTR_AMOUNT = "amount" diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index 8ecae8dc301..d0465fcc13c 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -7,12 +7,12 @@ from typing import cast from python_picnic_api2 import PicnicAPI import voluptuous as vol +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import ( ATTR_AMOUNT, - ATTR_CONFIG_ENTRY_ID, ATTR_PRODUCT_ID, ATTR_PRODUCT_IDENTIFIERS, ATTR_PRODUCT_NAME, diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index 8055074f395..794afd2287b 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -8,6 +8,5 @@ CONF_SERIAL_NUMBER = "serial_number" CONF_IMPORTED_NAMES = "imported_names" ATTR_DURATION = "duration" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" TIMEOUT_SECONDS = 20 diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 236e1707461..42a29ee6ef4 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -59,7 +59,7 @@ PLATFORMS = [ Platform.UPDATE, ] DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) -FIRMWARE_UPDATE_INTERVAL = timedelta(hours=12) +FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24) NUM_CRED_ERRORS = 3 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 48f6b709c23..912427fa881 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -24,7 +24,7 @@ async def async_get_config_entry_diagnostics( IPC_cam[ch]["hardware version"] = api.camera_hardware_version(ch) IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) - if (signal := api.wifi_signal(ch)) is not None: + if (signal := api.wifi_signal(ch)) is not None and api.wifi_connection(ch): IPC_cam[ch]["WiFi signal"] = signal chimes: dict[int, dict[str, Any]] = {} @@ -43,7 +43,7 @@ async def async_get_config_entry_diagnostics( "HTTP(S) port": api.port, "Baichuan port": api.baichuan.port, "Baichuan only": api.baichuan_only, - "WiFi connection": api.wifi_connection, + "WiFi connection": api.wifi_connection(), "WiFi signal": api.wifi_signal(), "RTMP enabled": api.rtmp_enabled, "RTSP enabled": api.rtsp_enabled, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index efd9f1121b6..4ad80dda807 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.14.5"] + "requirements": ["reolink-aio==0.14.6"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 9c8c685d898..f716340e06e 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -422,9 +422,7 @@ class ReolinkVODMediaSource(MediaSource): file_name = f"{file.start_time.time()} {file.duration}" if file.triggers != file.triggers.NONE: file_name += " " + " ".join( - str(trigger.name).title() - for trigger in file.triggers - if trigger != trigger.NONE + str(trigger.name).title() for trigger in file.triggers ) children.append( diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index d0222b0cffb..da879194e88 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -116,6 +116,7 @@ NUMBER_ENTITIES = ( cmd_id=[289, 438], translation_key="floodlight_brightness", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=1, native_max_value=100, @@ -407,8 +408,8 @@ NUMBER_ENTITIES = ( key="auto_track_limit_left", cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_left", - mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=-1, native_max_value=2700, @@ -420,8 +421,8 @@ NUMBER_ENTITIES = ( key="auto_track_limit_right", cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_right", - mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=-1, native_max_value=2700, @@ -435,6 +436,7 @@ NUMBER_ENTITIES = ( translation_key="auto_track_disappear_time", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.DURATION, + entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -451,6 +453,7 @@ NUMBER_ENTITIES = ( translation_key="auto_track_stop_time", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.DURATION, + entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index cd03f2b59b5..9b9a78c8ce7 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -148,7 +148,7 @@ HOST_SENSORS = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_registry_enabled_default=False, value=lambda api: api.wifi_signal(), - supported=lambda api: api.supported(None, "wifi") and api.wifi_connection, + supported=lambda api: api.supported(None, "wifi") and api.wifi_connection(), ), ReolinkHostSensorEntityDescription( key="cpu_usage", diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 058fffbdb1c..4bf3c49a726 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -109,7 +109,6 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE | VacuumEntityFeature.CLEAN_SPOT @@ -142,11 +141,6 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): assert self._device_status.state is not None return STATE_CODE_TO_STATE.get(self._device_status.state) - @property - def battery_level(self) -> int | None: - """Return the battery level of the vacuum cleaner.""" - return self._device_status.battery - @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" diff --git a/homeassistant/components/seventeentrack/const.py b/homeassistant/components/seventeentrack/const.py index 988a01f0022..bbf2fcf2638 100644 --- a/homeassistant/components/seventeentrack/const.py +++ b/homeassistant/components/seventeentrack/const.py @@ -48,4 +48,3 @@ SERVICE_ARCHIVE_PACKAGE = "archive_package" ATTR_PACKAGE_STATE = "package_state" ATTR_PACKAGE_TRACKING_NUMBER = "package_tracking_number" ATTR_PACKAGE_FRIENDLY_NAME = "package_friendly_name" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 531ff2aea43..bd39b00071f 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -6,7 +6,7 @@ from pyseventeentrack.package import PACKAGE_STATUS_MAP, Package import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_FRIENDLY_NAME, ATTR_LOCATION from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -20,7 +20,6 @@ from homeassistant.util import slugify from . import SeventeenTrackCoordinator from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_DESTINATION_COUNTRY, ATTR_INFO_TEXT, ATTR_ORIGIN_COUNTRY, diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 5bbfc33ae5b..79a50ef4732 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco", "sonos_websocket"], - "requirements": ["soco==0.30.9", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.11", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index 7b4c28540fc..65b20949fe1 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -6,5 +6,4 @@ from typing import Final DOMAIN: Final = "stookwijzer" LOGGER = logging.getLogger(__package__) -ATTR_CONFIG_ENTRY_ID = "config_entry_id" SERVICE_GET_FORECAST = "get_forecast" diff --git a/homeassistant/components/stookwijzer/services.py b/homeassistant/components/stookwijzer/services.py index e8c12717a21..1543d7e8777 100644 --- a/homeassistant/components/stookwijzer/services.py +++ b/homeassistant/components/stookwijzer/services.py @@ -5,6 +5,7 @@ from typing import Required, TypedDict, cast import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -13,7 +14,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import ServiceValidationError -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_GET_FORECAST +from .const import DOMAIN, SERVICE_GET_FORECAST from .coordinator import StookwijzerConfigEntry SERVICE_GET_FORECAST_SCHEMA = vol.Schema( diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index f48505b4993..415d0a04e7c 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -131,13 +131,13 @@ class SunCondition(Condition): self._hass = hass @classmethod - async def async_validate_condition_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] - async def async_condition_from_config(self) -> ConditionCheckerType: + async def async_get_checker(self) -> ConditionCheckerType: """Wrap action method with sun based condition.""" before = self._config.get("before") after = self._config.get("after") @@ -153,7 +153,7 @@ class SunCondition(Condition): CONDITIONS: dict[str, type[Condition]] = { - "sun": SunCondition, + "_": SunCondition, } diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py index 10bfc0d0355..c6637adbbef 100644 --- a/homeassistant/components/swiss_public_transport/const.py +++ b/homeassistant/components/swiss_public_transport/const.py @@ -29,7 +29,6 @@ PLACEHOLDERS = { "opendata_url": "http://transport.opendata.ch", } -ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id" ATTR_LIMIT: Final = "limit" SERVICE_FETCH_CONNECTIONS = "fetch_connections" diff --git a/homeassistant/components/swiss_public_transport/services.py b/homeassistant/components/swiss_public_transport/services.py index 1ac116b4ca9..9297bd4b409 100644 --- a/homeassistant/components/swiss_public_transport/services.py +++ b/homeassistant/components/swiss_public_transport/services.py @@ -3,6 +3,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -19,7 +20,6 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.update_coordinator import UpdateFailed from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_LIMIT, CONNECTIONS_COUNT, CONNECTIONS_MAX, diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 22168c21f97..6ed11acda08 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.68.2"] + "requirements": ["PySwitchbot==0.68.3"] } diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index be5fb1866ea..200b323d377 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -440,6 +440,12 @@ } } }, + "issues": { + "deprecated_battery_level": { + "title": "Deprecated battery level option in {entity_name}", + "description": "The template vacuum options `battery_level` and `battery_level_template` are being removed in 2026.8.\n\nPlease remove the `battery_level` or `battery_level_template` option from the YAML configuration for {entity_id} ({entity_name})." + } + }, "options": { "step": { "alarm_control_panel": { @@ -753,7 +759,7 @@ "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", "state": "[%key:component::template::config::step::sensor::data_description::state%]", - "unit_of_measurement": "[%key:component::template::config::step::sensor::data_description::state%]" + "unit_of_measurement": "[%key:component::template::config::step::sensor::data_description::unit_of_measurement%]" }, "sections": { "advanced_options": { diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1abfdbd00da..242a534187a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -34,11 +34,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + template, +) from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -188,6 +193,26 @@ def async_create_preview_vacuum( ) +def create_issue( + hass: HomeAssistant, supported_features: int, name: str, entity_id: str +) -> None: + """Create the battery_level issue.""" + if supported_features & VacuumEntityFeature.BATTERY: + key = "deprecated_battery_level" + ir.async_create_issue( + hass, + DOMAIN, + f"{key}_{entity_id}", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=key, + translation_placeholders={ + "entity_name": name, + "entity_id": entity_id, + }, + ) + + class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" @@ -369,6 +394,16 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + create_issue( + self.hass, + self._attr_supported_features, + self._attr_name or DEFAULT_NAME, + self.entity_id, + ) + @callback def _async_setup_templates(self) -> None: """Set up templates.""" @@ -434,6 +469,16 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): self._to_render_simple.append(key) self._parse_result.add(key) + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + create_issue( + self.hass, + self._attr_supported_features, + self._attr_name or DEFAULT_NAME, + self.entity_id, + ) + @callback def _handle_coordinator_update(self) -> None: """Handle update of the data.""" diff --git a/homeassistant/components/traccar_server/manifest.json b/homeassistant/components/traccar_server/manifest.json index 5fac2f108f7..18c30e52233 100644 --- a/homeassistant/components/traccar_server/manifest.json +++ b/homeassistant/components/traccar_server/manifest.json @@ -1,7 +1,7 @@ { "domain": "traccar_server", "name": "Traccar Server", - "codeowners": ["@ludeeus"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/traccar_server", "iot_class": "local_push", diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 106075e9314..6ed8f0253ab 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -153,11 +153,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool # Register known device IDs device_registry = dr.async_get(hass) for device in manager.device_map.values(): + LOGGER.debug( + "Register device %s: %s (function: %s, status range: %s)", + device.id, + device.status, + device.function, + device.status_range, + ) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.id)}, manufacturer="Tuya", name=device.name, + # Note: the model is overridden via entity.device_info property + # when the entity is created. If no entities are generated, it will + # stay as unsupported model=f"{device.product_name} (unsupported)", model_id=device.product_id, ) @@ -237,6 +247,14 @@ class DeviceListener(SharingDeviceListener): # Ensure the device isn't present stale self.hass.add_job(self.async_remove_device, device.id) + LOGGER.debug( + "Add device %s: %s (function: %s, status range: %s)", + device.id, + device.status, + device.function, + device.status_range, + ) + dispatcher_send(self.hass, TUYA_DISCOVERY_NEW, [device.id]) def remove_device(self, device_id: str) -> None: diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 61985fb7622..d08a3bef7ce 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -22,6 +22,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData +from .util import get_dpcode @dataclass(frozen=True) @@ -140,7 +141,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): self._master_state = enum_type # Determine alarm message - if dp_code := self.find_dpcode(description.alarm_msg, prefer_function=True): + if dp_code := get_dpcode(self.device, description.alarm_msg): self._alarm_msg_dpcode = dp_code @property diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 4fef11a7335..fd3f0cfcb7e 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -314,6 +314,16 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Zigbee gateway + # Undocumented + "wg2": ( + TuyaBinarySensorEntityDescription( + key=DPCode.MASTER_STATE, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + on_value="alarm", + ), + ), # Thermostatic Radiator Valve # Not documented "wkf": ( diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index c8071e68397..ecfc96f1d67 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -27,6 +27,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData +from .util import get_dpcode TUYA_HVAC_TO_HA = { "auto": HVACMode.HEAT_COOL, @@ -229,7 +230,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): self._attr_hvac_modes.append(description.switch_only_hvac_mode) self._attr_preset_modes = unknown_hvac_modes self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE - elif self.find_dpcode(DPCode.SWITCH, prefer_function=True): + elif get_dpcode(self.device, DPCode.SWITCH): self._attr_hvac_modes = [ HVACMode.OFF, description.switch_only_hvac_mode, @@ -261,24 +262,24 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): self._fan_mode_dp_code = enum_type.dpcode # Determine swing modes - if self.find_dpcode( + if get_dpcode( + self.device, ( DPCode.SHAKE, DPCode.SWING, DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL, ), - prefer_function=True, ): self._attr_supported_features |= ClimateEntityFeature.SWING_MODE self._attr_swing_modes = [SWING_OFF] - if self.find_dpcode((DPCode.SHAKE, DPCode.SWING), prefer_function=True): + if get_dpcode(self.device, (DPCode.SHAKE, DPCode.SWING)): self._attr_swing_modes.append(SWING_ON) - if self.find_dpcode(DPCode.SWITCH_HORIZONTAL, prefer_function=True): + if get_dpcode(self.device, DPCode.SWITCH_HORIZONTAL): self._attr_swing_modes.append(SWING_HORIZONTAL) - if self.find_dpcode(DPCode.SWITCH_VERTICAL, prefer_function=True): + if get_dpcode(self.device, DPCode.SWITCH_VERTICAL): self._attr_swing_modes.append(SWING_VERTICAL) if DPCode.SWITCH in self.device.function: diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 87f80755e8b..38661d548a7 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -109,6 +109,7 @@ class DPCode(StrEnum): ANION = "anion" # Ionizer unit ARM_DOWN_PERCENT = "arm_down_percent" ARM_UP_PERCENT = "arm_up_percent" + ATMOSPHERIC_PRESSTURE = "atmospheric_pressture" # Typo is in Tuya API BASIC_ANTI_FLICKER = "basic_anti_flicker" BASIC_DEVICE_VOLUME = "basic_device_volume" BASIC_FLIP = "basic_flip" @@ -215,8 +216,13 @@ class DPCode(StrEnum): HUMIDITY = "humidity" # Humidity HUMIDITY_CURRENT = "humidity_current" # Current humidity HUMIDITY_INDOOR = "humidity_indoor" # Indoor humidity + HUMIDITY_OUTDOOR = "humidity_outdoor" # Outdoor humidity + HUMIDITY_OUTDOOR_1 = "humidity_outdoor_1" # Outdoor humidity + HUMIDITY_OUTDOOR_2 = "humidity_outdoor_2" # Outdoor humidity + HUMIDITY_OUTDOOR_3 = "humidity_outdoor_3" # Outdoor humidity HUMIDITY_SET = "humidity_set" # Humidity setting HUMIDITY_VALUE = "humidity_value" # Humidity + INSTALLATION_HEIGHT = "installation_height" IPC_WORK_MODE = "ipc_work_mode" LED_TYPE_1 = "led_type_1" LED_TYPE_2 = "led_type_2" @@ -227,12 +233,18 @@ class DPCode(StrEnum): LEVEL_CURRENT = "level_current" LIGHT = "light" # Light LIGHT_MODE = "light_mode" + LIQUID_DEPTH = "liquid_depth" + LIQUID_DEPTH_MAX = "liquid_depth_max" + LIQUID_LEVEL_PERCENT = "liquid_level_percent" + LIQUID_STATE = "liquid_state" LOCK = "lock" # Lock / Child lock MASTER_MODE = "master_mode" # alarm mode MASTER_STATE = "master_state" # alarm state MACH_OPERATE = "mach_operate" MANUAL_FEED = "manual_feed" MATERIAL = "material" # Material + MAX_SET = "max_set" + MINI_SET = "mini_set" MODE = "mode" # Working mode / Mode MOODLIGHTING = "moodlighting" # Mood light MOTION_RECORD = "motion_record" @@ -360,6 +372,15 @@ class DPCode(StrEnum): TEMP_CURRENT_EXTERNAL = ( "temp_current_external" # Current external temperature in Celsius ) + TEMP_CURRENT_EXTERNAL_1 = ( + "temp_current_external_1" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_2 = ( + "temp_current_external_2" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_3 = ( + "temp_current_external_3" # Current external temperature in Celsius + ) TEMP_CURRENT_EXTERNAL_F = ( "temp_current_external_f" # Current external temperature in Fahrenheit ) @@ -405,6 +426,7 @@ class DPCode(StrEnum): WINDOW_CHECK = "window_check" WINDOW_STATE = "window_state" WINDSPEED = "windspeed" + WINDSPEED_AVG = "windspeed_avg" WIRELESS_BATTERYLOCK = "wireless_batterylock" WIRELESS_ELECTRICITY = "wireless_electricity" WORK_MODE = "work_mode" # Working mode diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 7f34aa367ad..43e3f20deb4 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -23,6 +23,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData +from .util import get_dpcode @dataclass(frozen=True) @@ -202,7 +203,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): self._attr_supported_features = CoverEntityFeature(0) # Check if this cover is based on a switch or has controls - if self.find_dpcode(description.key, prefer_function=True): + if get_dpcode(self.device, description.key): if device.function[description.key].type == "Boolean": self._attr_supported_features |= ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index fbddfb0ab83..0ae0f793afd 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -72,22 +72,17 @@ class TuyaEntity(Entity): dptype: Literal[DPType.INTEGER], ) -> IntegerTypeData | None: ... - @overload def find_dpcode( self, dpcodes: str | DPCode | tuple[DPCode, ...] | None, *, prefer_function: bool = False, - ) -> DPCode | None: ... + dptype: DPType, + ) -> EnumTypeData | IntegerTypeData | None: + """Find type information for a matching DP code available for this device.""" + if dptype not in (DPType.ENUM, DPType.INTEGER): + raise NotImplementedError("Only ENUM and INTEGER types are supported") - def find_dpcode( - self, - dpcodes: str | DPCode | tuple[DPCode, ...] | None, - *, - prefer_function: bool = False, - dptype: DPType | None = None, - ) -> DPCode | EnumTypeData | IntegerTypeData | None: - """Find a matching DP code available on for this device.""" if dpcodes is None: return None @@ -100,11 +95,6 @@ class TuyaEntity(Entity): if prefer_function: order = ["function", "status_range"] - # When we are not looking for a specific datatype, we can append status for - # searching - if not dptype: - order.append("status") - for dpcode in dpcodes: for key in order: if dpcode not in getattr(self.device, key): @@ -133,9 +123,6 @@ class TuyaEntity(Entity): continue return integer_type - if dptype not in (DPType.ENUM, DPType.INTEGER): - return dpcode - return None def get_dptype( diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 90f4132cef0..fba42ad76cf 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -24,6 +24,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData, IntegerTypeData +from .util import get_dpcode TUYA_SUPPORT_TYPE = { # Dehumidifier @@ -90,8 +91,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity): """Init Tuya Fan Device.""" super().__init__(device, device_manager) - self._switch = self.find_dpcode( - (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), prefer_function=True + self._switch = get_dpcode( + self.device, (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) ) self._attr_preset_modes = [] @@ -120,8 +121,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity): self._attr_supported_features |= FanEntityFeature.SET_SPEED self._speeds = enum_type - if dpcode := self.find_dpcode( - (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL), prefer_function=True + if dpcode := get_dpcode( + self.device, (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) ): self._oscillate = dpcode self._attr_supported_features |= FanEntityFeature.OSCILLATE @@ -267,7 +268,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity): return int(self._speed.remap_value_to(value, 1, 100)) if self._speeds is not None: - if (value := self.device.status.get(self._speeds.dpcode)) is None: + if ( + value := self.device.status.get(self._speeds.dpcode) + ) is None or value not in self._speeds.range: return None return ordered_list_item_to_percentage(self._speeds.range, value) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 06fdc1545c5..5def5c5e16c 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -21,7 +21,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData -from .util import ActionDPCodeNotFoundError +from .util import ActionDPCodeNotFoundError, get_dpcode @dataclass(frozen=True) @@ -105,8 +105,8 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): self._attr_unique_id = f"{super().unique_id}{description.key}" # Determine main switch DPCode - self._switch_dpcode = self.find_dpcode( - description.dpcode or DPCode(description.key), prefer_function=True + self._switch_dpcode = get_dpcode( + self.device, description.dpcode or DPCode(description.key) ) # Determine humidity parameters diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 7b73e825900..9848351047c 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -29,7 +29,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode from .entity import TuyaEntity from .models import IntegerTypeData -from .util import remap_value +from .util import get_dpcode, remap_value @dataclass @@ -515,9 +515,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): color_modes: set[ColorMode] = {ColorMode.ONOFF} # Determine DPCodes - self._color_mode_dpcode = self.find_dpcode( - description.color_mode, prefer_function=True - ) + self._color_mode_dpcode = get_dpcode(self.device, description.color_mode) if int_type := self.find_dpcode( description.brightness, dptype=DPType.INTEGER, prefer_function=True @@ -532,7 +530,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ) if ( - dpcode := self.find_dpcode(description.color_data, prefer_function=True) + dpcode := get_dpcode(self.device, description.color_data) ) and self.get_dptype(dpcode) == DPType.JSON: self._color_data_dpcode = dpcode color_modes.add(ColorMode.HS) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index e7988adfafb..88216ae3d06 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -339,6 +339,32 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Tank Level Sensor + # Note: Undocumented + "ywcgq": ( + NumberEntityDescription( + key=DPCode.MAX_SET, + translation_key="alarm_maximum", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.MINI_SET, + translation_key="alarm_minimum", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.INSTALLATION_HEIGHT, + translation_key="installation_height", + device_class=NumberDeviceClass.DISTANCE, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.LIQUID_DEPTH_MAX, + translation_key="maximum_liquid_depth", + device_class=NumberDeviceClass.DISTANCE, + entity_category=EntityCategory.CONFIG, + ), + ), # Vibration Sensor # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno "zd": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index da7a57b1be2..9eb05186f63 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -846,6 +846,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_1, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_2, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_3, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "3"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, translation_key="humidity", @@ -858,12 +879,51 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR, + translation_key="humidity_outdoor", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_1, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_2, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_3, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "3"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.ATMOSPHERIC_PRESSTURE, + translation_key="air_pressure", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.BRIGHT_VALUE, translation_key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.WINDSPEED_AVG, + translation_key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), *BATTERY_SENSORS, ), # Gas Detector @@ -925,6 +985,13 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="rolling_brush_life", state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.ELECTRICITY_LEFT, + translation_key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), ), # Smart Water Timer "sfkzq": ( @@ -1234,6 +1301,25 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Tank Level Sensor + # Note: Undocumented + "ywcgq": ( + TuyaSensorEntityDescription( + key=DPCode.LIQUID_STATE, + translation_key="liquid_state", + ), + TuyaSensorEntityDescription( + key=DPCode.LIQUID_DEPTH, + translation_key="depth", + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.LIQUID_LEVEL_PERCENT, + translation_key="liquid_level", + state_class=SensorStateClass.MEASUREMENT, + ), + ), # Vibration Sensor # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno "zd": BATTERY_SENSORS, diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 97d623d7c21..d660c9c910d 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -213,6 +213,18 @@ }, "siren_duration": { "name": "Siren duration" + }, + "alarm_maximum": { + "name": "Alarm maximum" + }, + "alarm_minimum": { + "name": "Alarm minimum" + }, + "installation_height": { + "name": "Installation height" + }, + "maximum_liquid_depth": { + "name": "Maximum liquid depth" } }, "select": { @@ -502,9 +514,21 @@ "temperature_external": { "name": "Probe temperature" }, + "indexed_temperature_external": { + "name": "Probe temperature channel {index}" + }, "humidity": { "name": "[%key:component::sensor::entity_component::humidity::name%]" }, + "humidity_outdoor": { + "name": "Outdoor humidity" + }, + "indexed_humidity_outdoor": { + "name": "Outdoor humidity channel {index}" + }, + "air_pressure": { + "name": "Air pressure" + }, "pm25": { "name": "[%key:component::sensor::entity_component::pm25::name%]" }, @@ -699,6 +723,20 @@ "charging": "[%key:common::state::charging%]", "charge_done": "Charge done" } + }, + "liquid_state": { + "name": "Liquid state", + "state": { + "normal": "[%key:common::state::normal%]", + "lower_alarm": "[%key:common::state::low%]", + "upper_alarm": "[%key:common::state::high%]" + } + }, + "liquid_depth": { + "name": "Liquid depth" + }, + "liquid_level": { + "name": "Liquid level" } }, "switch": { diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index f6d5df9af73..ecd7d9f4f44 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -758,6 +758,18 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_placeholders={"index": "4"}, device_class=SwitchDeviceClass.OUTLET, ), + SwitchEntityDescription( + key=DPCode.SWITCH_5, + translation_key="indexed_switch", + translation_placeholders={"index": "5"}, + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_6, + translation_key="indexed_switch", + translation_placeholders={"index": "6"}, + device_class=SwitchDeviceClass.OUTLET, + ), SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index 916a7cfddf4..af6a78c1476 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -9,6 +9,29 @@ from homeassistant.exceptions import ServiceValidationError from .const import DOMAIN, DPCode +def get_dpcode( + device: CustomerDevice, dpcodes: str | DPCode | tuple[DPCode, ...] | None +) -> DPCode | None: + """Get the first matching DPCode from the device or return None.""" + if dpcodes is None: + return None + + if isinstance(dpcodes, DPCode): + dpcodes = (dpcodes,) + elif isinstance(dpcodes, str): + dpcodes = (DPCode(dpcodes),) + + for dpcode in dpcodes: + if ( + dpcode in device.function + or dpcode in device.status + or dpcode in device.status_range + ): + return dpcode + + return None + + def remap_value( value: float, from_min: float = 0, diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index d61a624f027..c32d773c792 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -18,7 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity -from .models import EnumTypeData, IntegerTypeData +from .models import EnumTypeData +from .util import get_dpcode TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { @@ -77,7 +78,6 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): """Tuya Vacuum Device.""" _fan_speed: EnumTypeData | None = None - _battery_level: IntegerTypeData | None = None _attr_name = None def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: @@ -89,11 +89,11 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self._attr_supported_features = ( VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE ) - if self.find_dpcode(DPCode.PAUSE, prefer_function=True): + if get_dpcode(self.device, DPCode.PAUSE): self._attr_supported_features |= VacuumEntityFeature.PAUSE self._return_home_use_switch_charge = False - if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True): + if get_dpcode(self.device, DPCode.SWITCH_CHARGE): self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME self._return_home_use_switch_charge = True elif ( @@ -103,10 +103,10 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): ) and TUYA_MODE_RETURN_HOME in enum_type.range: self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME - if self.find_dpcode(DPCode.SEEK, prefer_function=True): + if get_dpcode(self.device, DPCode.SEEK): self._attr_supported_features |= VacuumEntityFeature.LOCATE - if self.find_dpcode(DPCode.POWER_GO, prefer_function=True): + if get_dpcode(self.device, DPCode.POWER_GO): self._attr_supported_features |= ( VacuumEntityFeature.STOP | VacuumEntityFeature.START ) @@ -118,19 +118,6 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self._attr_fan_speed_list = enum_type.range self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED - if int_type := self.find_dpcode(DPCode.ELECTRICITY_LEFT, dptype=DPType.INTEGER): - self._attr_supported_features |= VacuumEntityFeature.BATTERY - self._battery_level = int_type - - @property - def battery_level(self) -> int | None: - """Return Tuya device state.""" - if self._battery_level is None or not ( - status := self.device.status.get(DPCode.ELECTRICITY_LEFT) - ): - return None - return round(self._battery_level.scale_value(status)) - @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 4b7a6907455..11db9108db3 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -79,6 +79,8 @@ DEFAULT_NAME = "Vacuum cleaner robot" _DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(VacuumActivity.IDLE, "2026.1") _DEPRECATED_STATE_PAUSED = DeprecatedConstantEnum(VacuumActivity.PAUSED, "2026.1") +_BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",) + class VacuumEntityFeature(IntFlag): """Supported features of the vacuum entity.""" @@ -321,7 +323,11 @@ class StateVacuumEntity( Integrations should implement a sensor instead. """ - if self.platform: + if ( + self.platform + and self.platform.platform_name + not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS + ): # Don't report usage until after entity added to hass, after init report_usage( f"is setting the {property} which has been deprecated." @@ -341,7 +347,11 @@ class StateVacuumEntity( Integrations should remove the battery supported feature when migrating battery level and icon to a sensor. """ - if self.platform: + if ( + self.platform + and self.platform.platform_name + not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS + ): # Don't report usage until after entity added to hass, after init report_usage( f"is setting the battery supported feature which has been deprecated." diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index fb729707154..b62d7b828af 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -8,6 +8,7 @@ from aiowebostv import WebOsClient, WebOsTvPairError from homeassistant.components import notify as hass_notify from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, @@ -20,13 +21,7 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import ( - ATTR_CONFIG_ENTRY_ID, - DATA_HASS_CONFIG, - DOMAIN, - PLATFORMS, - WEBOSTV_EXCEPTIONS, -) +from .const import DATA_HASS_CONFIG, DOMAIN, PLATFORMS, WEBOSTV_EXCEPTIONS from .helpers import WebOsTvConfigEntry, update_client_key CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 118ea7b32db..e8774fa24e3 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -13,7 +13,6 @@ DATA_HASS_CONFIG = "hass_config" DEFAULT_NAME = "LG webOS TV" ATTR_BUTTON = "button" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_PAYLOAD = "payload" ATTR_SOUND_OUTPUT = "sound_output" diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 3966cea5e92..a2e9753c172 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -7,13 +7,13 @@ from typing import Any from aiowebostv import WebOsClient from homeassistant.components.notify import ATTR_DATA, BaseNotificationService -from homeassistant.const import ATTR_ICON +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import WebOsTvConfigEntry -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, WEBOSTV_EXCEPTIONS +from .const import DOMAIN, WEBOSTV_EXCEPTIONS PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 32edd5d3f6a..d2309702728 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.77"] + "requirements": ["holidays==0.78"] } diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 31adb17d7f5..39f5267006e 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -13,6 +13,6 @@ "documentation": "https://www.home-assistant.io/integrations/wyoming", "integration_type": "service", "iot_class": "local_push", - "requirements": ["wyoming==1.7.1"], + "requirements": ["wyoming==1.7.2"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index aa68009ac72..9086bb15575 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.2"] + "requirements": ["yalexs==8.11.1", "yalexs-ble==3.1.2"] } diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index facde4ead3a..9842fa7a0f3 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.65"], + "requirements": ["zha==0.0.67"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zimi/quality_scale.yaml b/homeassistant/components/zimi/quality_scale.yaml index 98e6c5b627c..8b8b85c71f4 100644 --- a/homeassistant/components/zimi/quality_scale.yaml +++ b/homeassistant/components/zimi/quality_scale.yaml @@ -16,6 +16,7 @@ rules: status: done comment: | https://mark_hannon@bitbucket.org/mark_hannon/zcc.git + https://bitbucket.org/mark_hannon/zcc/src/master/bitbucket-pipelines.yml docs-actions: status: exempt comment: | diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index 0fb30eeda9c..cc2429ed3a4 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -100,13 +100,13 @@ class ZoneCondition(Condition): self._config = config @classmethod - async def async_validate_condition_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] - async def async_condition_from_config(self) -> ConditionCheckerType: + async def async_get_checker(self) -> ConditionCheckerType: """Wrap action method with zone based condition.""" entity_ids = self._config.get(CONF_ENTITY_ID, []) zone_entity_ids = self._config.get(CONF_ZONE, []) @@ -147,7 +147,7 @@ class ZoneCondition(Condition): CONDITIONS: dict[str, type[Condition]] = { - "zone": ZoneCondition, + "_": ZoneCondition, } diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 923cd776f92..af42f024e6a 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -509,7 +509,7 @@ class ControllerEvents: ) ) - await self.async_check_preprovisioned_device(node) + await self.async_check_pre_provisioned_device(node) if node.is_controller_node: # Create a controller status sensor for each device @@ -637,8 +637,8 @@ class ControllerEvents: f"{DOMAIN}.identify_controller.{dev_id[1]}", ) - async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None: - """Check if the node was preprovisioned and update the device registry.""" + async def async_check_pre_provisioned_device(self, node: ZwaveNode) -> None: + """Check if the node was pre-provisioned and update the device registry.""" provisioning_entry = ( await self.driver_events.driver.controller.async_get_provisioning_entry( node.node_id @@ -648,29 +648,37 @@ class ControllerEvents: provisioning_entry and provisioning_entry.additional_properties and "device_id" in provisioning_entry.additional_properties - ): - preprovisioned_device = self.dev_reg.async_get( - provisioning_entry.additional_properties["device_id"] + and ( + pre_provisioned_device := self.dev_reg.async_get( + provisioning_entry.additional_properties["device_id"] + ) ) + and (dsk_identifier := (DOMAIN, f"provision_{provisioning_entry.dsk}")) + in pre_provisioned_device.identifiers + ): + driver = self.driver_events.driver + device_id = get_device_id(driver, node) + device_id_ext = get_device_id_ext(driver, node) + new_identifiers = pre_provisioned_device.identifiers.copy() + new_identifiers.remove(dsk_identifier) + new_identifiers.add(device_id) + if device_id_ext: + new_identifiers.add(device_id_ext) - if preprovisioned_device: - dsk = provisioning_entry.dsk - dsk_identifier = (DOMAIN, f"provision_{dsk}") - - # If the pre-provisioned device has the DSK identifier, remove it - if dsk_identifier in preprovisioned_device.identifiers: - driver = self.driver_events.driver - device_id = get_device_id(driver, node) - device_id_ext = get_device_id_ext(driver, node) - new_identifiers = preprovisioned_device.identifiers.copy() - new_identifiers.remove(dsk_identifier) - new_identifiers.add(device_id) - if device_id_ext: - new_identifiers.add(device_id_ext) - self.dev_reg.async_update_device( - preprovisioned_device.id, - new_identifiers=new_identifiers, - ) + if self.dev_reg.async_get_device(identifiers=new_identifiers): + # If a device entry is registered with the node ID based identifiers, + # just remove the device entry with the DSK identifier. + self.dev_reg.async_update_device( + pre_provisioned_device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + else: + # Add the node ID based identifiers to the device entry + # with the DSK identifier and remove the DSK identifier. + self.dev_reg.async_update_device( + pre_provisioned_device.id, + new_identifiers=new_identifiers, + ) async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: """Register node in dev reg.""" diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 0ccf51539d6..69987385d5a 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -92,7 +92,6 @@ ATTR_CURRENT_VALUE = "current_value" ATTR_CURRENT_VALUE_RAW = "current_value_raw" ATTR_DESCRIPTION = "description" ATTR_EVENT_SOURCE = "event_source" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_PARTIAL_DICT_MATCH = "partial_dict_match" # service constants diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py index e934faec70c..d25737ffd59 100644 --- a/homeassistant/components/zwave_js/trigger.py +++ b/homeassistant/components/zwave_js/trigger.py @@ -8,8 +8,8 @@ from homeassistant.helpers.trigger import Trigger from .triggers import event, value_updated TRIGGERS = { - event.PLATFORM_TYPE: event.EventTrigger, - value_updated.PLATFORM_TYPE: value_updated.ValueUpdatedTrigger, + event.RELATIVE_PLATFORM_TYPE: event.EventTrigger, + value_updated.RELATIVE_PLATFORM_TYPE: value_updated.ValueUpdatedTrigger, } diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 52c24055052..150a32113e6 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -11,7 +11,12 @@ from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP, Driver from zwave_js_server.model.node import NODE_EVENT_MODEL_MAP -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_PLATFORM, +) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -19,7 +24,6 @@ from homeassistant.helpers.trigger import Trigger, TriggerActionType, TriggerInf from homeassistant.helpers.typing import ConfigType from ..const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_EVENT, ATTR_EVENT_DATA, ATTR_EVENT_SOURCE, @@ -34,8 +38,11 @@ from ..helpers import ( ) from .trigger_helpers import async_bypass_dynamic_config_validation +# Relative platform type should be +RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}" + # Platform type should be . -PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" +PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}" def validate_non_node_event_source(obj: dict) -> dict: @@ -260,13 +267,13 @@ class EventTrigger(Trigger): self._hass = hass @classmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" return await async_validate_trigger_config(hass, config) - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, diff --git a/homeassistant/components/zwave_js/triggers/trigger_helpers.py b/homeassistant/components/zwave_js/triggers/trigger_helpers.py index 917d207109f..03792771bd3 100644 --- a/homeassistant/components/zwave_js/triggers/trigger_helpers.py +++ b/homeassistant/components/zwave_js/triggers/trigger_helpers.py @@ -1,12 +1,12 @@ """Helpers for Z-Wave JS custom triggers.""" from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType -from ..const import ATTR_CONFIG_ENTRY_ID, DOMAIN +from ..const import DOMAIN @callback diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index a50053fa2db..f46592769cb 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -37,8 +37,11 @@ from ..const import ( from ..helpers import async_get_nodes_from_targets, get_device_id from .trigger_helpers import async_bypass_dynamic_config_validation +# Relative platform type should be +RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}" + # Platform type should be . -PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" +PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}" ATTR_FROM = "from" ATTR_TO = "to" @@ -213,13 +216,13 @@ class ValueUpdatedTrigger(Trigger): self._hass = hass @classmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" return await async_validate_trigger_config(hass, config) - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 42a4b4cf6dd..88e1a22c00f 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -10,7 +10,6 @@ from datetime import datetime, timedelta from typing import Any, Final, cast from awesomeversion import AwesomeVersion -from zwave_js_server.const import NodeStatus from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedZWaveCommand from zwave_js_server.model.driver import Driver from zwave_js_server.model.firmware import ( @@ -192,7 +191,6 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): self.entity_description = entity_description self.node = node self._latest_version_firmware: FirmwareUpdateInfo | None = None - self._status_unsub: Callable[[], None] | None = None self._poll_unsub: Callable[[], None] | None = None self._progress_unsub: Callable[[], None] | None = None self._finished_unsub: Callable[[], None] | None = None @@ -213,12 +211,6 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): """Return ZWave Node Firmware Update specific state data to be restored.""" return ZWaveFirmwareUpdateExtraStoredData(self._latest_version_firmware) - @callback - def _update_on_status_change(self, _: dict[str, Any]) -> None: - """Update the entity when node is awake.""" - self._status_unsub = None - self.hass.async_create_task(self._async_update()) - @callback def update_progress(self, event: dict[str, Any]) -> None: """Update install progress on event.""" @@ -270,14 +262,6 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): ) return - # If device is asleep, wait for it to wake up before attempting an update - if self.node.status == NodeStatus.ASLEEP: - if not self._status_unsub: - self._status_unsub = self.node.once( - "wake up", self._update_on_status_change - ) - return - try: # Retrieve all firmware updates including non-stable ones but filter # non-stable channels out @@ -436,10 +420,6 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed.""" - if self._status_unsub: - self._status_unsub() - self._status_unsub = None - if self._poll_unsub: self._poll_unsub() self._poll_unsub = None diff --git a/homeassistant/const.py b/homeassistant/const.py index 1983932813e..b678e02569c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -469,6 +469,9 @@ ATTR_NAME: Final = "name" # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID: Final = "entity_id" +# Contains one string, the config entry ID +ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id" + # Contains one string or a list of strings, each being an area id ATTR_AREA_ID: Final = "area_id" diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index f5303f09302..da6cab4bc22 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -386,6 +386,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "inkbird", "local_name": "Ink@IAM-T1", }, + { + "connectable": True, + "domain": "inkbird", + "local_name": "Ink@IAM-T2", + }, { "connectable": True, "domain": "inkbird", @@ -396,6 +401,16 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ ], "manufacturer_id": 12628, }, + { + "connectable": False, + "domain": "inkbird", + "manufacturer_data_start": [ + 0, + 98, + 0, + ], + "manufacturer_id": 12884, + }, { "connectable": True, "domain": "iron_os", diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py new file mode 100644 index 00000000000..52a0fc13255 --- /dev/null +++ b/homeassistant/helpers/automation.py @@ -0,0 +1,21 @@ +"""Helpers for automation.""" + + +def get_absolute_description_key(domain: str, key: str) -> str: + """Return the absolute description key.""" + if not key.startswith("_"): + return f"{domain}.{key}" + key = key[1:] # Remove leading underscore + if not key: + return domain + return key + + +def get_relative_description_key(domain: str, key: str) -> str: + """Return the relative description key.""" + platform, *subtype = key.split(".", 1) + if platform != domain: + return f"_{key}" + if not subtype: + return "_" + return subtype[0] diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 3c6120f523f..d9f16217c2e 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -58,9 +58,9 @@ from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey from homeassistant.util.yaml import load_yaml_dict -from homeassistant.util.yaml.loader import JSON_TYPE from . import config_validation as cv, entity_registry as er +from .automation import get_absolute_description_key, get_relative_description_key from .integration_platform import async_process_integration_platforms from .template import Template, render_complex from .trace import ( @@ -132,7 +132,7 @@ def starts_with_dot(key: str) -> str: _CONDITIONS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, starts_with_dot)): object, - cv.slug: vol.Any(None, _CONDITION_SCHEMA), + cv.underscore_slug: vol.Any(None, _CONDITION_SCHEMA), } ) @@ -171,6 +171,9 @@ async def _register_condition_platform( if hasattr(platform, "async_get_conditions"): for condition_key in await platform.async_get_conditions(hass): + condition_key = get_absolute_description_key( + integration_domain, condition_key + ) hass.data[CONDITIONS][condition_key] = integration_domain new_conditions.add(condition_key) else: @@ -199,14 +202,14 @@ class Condition(abc.ABC): @classmethod @abc.abstractmethod - async def async_validate_condition_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" @abc.abstractmethod - async def async_condition_from_config(self) -> ConditionCheckerType: - """Evaluate state based on configuration.""" + async def async_get_checker(self) -> ConditionCheckerType: + """Get the condition checker.""" class ConditionProtocol(Protocol): @@ -288,22 +291,21 @@ def trace_condition_function(condition: ConditionCheckerType) -> ConditionChecke async def _async_get_condition_platform( - hass: HomeAssistant, config: ConfigType -) -> ConditionProtocol | None: - condition_key: str = config[CONF_CONDITION] - platform_and_sub_type = condition_key.partition(".") + hass: HomeAssistant, condition_key: str +) -> tuple[str, ConditionProtocol | None]: + platform_and_sub_type = condition_key.split(".") platform: str | None = platform_and_sub_type[0] platform = _PLATFORM_ALIASES.get(platform, platform) if platform is None: - return None + return "", None try: integration = await async_get_integration(hass, platform) except IntegrationNotFound: raise HomeAssistantError( - f'Invalid condition "{condition_key}" specified {config}' + f'Invalid condition "{condition_key}" specified' ) from None try: - return await integration.async_get_platform("condition") + return platform, await integration.async_get_platform("condition") except ImportError: raise HomeAssistantError( f"Integration '{platform}' does not provide condition support" @@ -339,17 +341,20 @@ async def async_from_config( return disabled_condition - condition: str = config[CONF_CONDITION] + condition_key: str = config[CONF_CONDITION] factory: Any = None - platform = await _async_get_condition_platform(hass, config) + platform_domain, platform = await _async_get_condition_platform(hass, condition_key) if platform is not None: condition_descriptors = await platform.async_get_conditions(hass) - condition_instance = condition_descriptors[condition](hass, config) - return await condition_instance.async_condition_from_config() + relative_condition_key = get_relative_description_key( + platform_domain, condition_key + ) + condition_instance = condition_descriptors[relative_condition_key](hass, config) + return await condition_instance.async_get_checker() for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): - factory = getattr(sys.modules[__name__], fmt.format(condition), None) + factory = getattr(sys.modules[__name__], fmt.format(condition_key), None) if factory: break @@ -960,8 +965,9 @@ async def async_validate_condition_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - condition: str = config[CONF_CONDITION] - if condition in ("and", "not", "or"): + condition_key: str = config[CONF_CONDITION] + + if condition_key in ("and", "not", "or"): conditions = [] for sub_cond in config["conditions"]: sub_cond = await async_validate_condition_config(hass, sub_cond) @@ -969,16 +975,23 @@ async def async_validate_condition_config( config["conditions"] = conditions return config - platform = await _async_get_condition_platform(hass, config) + platform_domain, platform = await _async_get_condition_platform(hass, condition_key) + if platform is not None: condition_descriptors = await platform.async_get_conditions(hass) - if not (condition_class := condition_descriptors.get(condition)): - raise vol.Invalid(f"Invalid condition '{condition}' specified") - return await condition_class.async_validate_condition_config(hass, config) - if platform is None and condition in ("numeric_state", "state"): + relative_condition_key = get_relative_description_key( + platform_domain, condition_key + ) + if not (condition_class := condition_descriptors.get(relative_condition_key)): + raise vol.Invalid(f"Invalid condition '{condition_key}' specified") + return await condition_class.async_validate_config(hass, config) + + if platform is None and condition_key in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], - getattr(sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition)), + getattr( + sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition_key) + ), ) return validator(hass, config) @@ -1088,11 +1101,11 @@ def async_extract_devices(config: ConfigType | Template) -> set[str]: return referenced -def _load_conditions_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: +def _load_conditions_file(integration: Integration) -> dict[str, Any]: """Load conditions file for an integration.""" try: return cast( - JSON_TYPE, + dict[str, Any], _CONDITIONS_SCHEMA( load_yaml_dict(str(integration.file_path / "conditions.yaml")) ), @@ -1112,11 +1125,14 @@ def _load_conditions_file(hass: HomeAssistant, integration: Integration) -> JSON def _load_conditions_files( - hass: HomeAssistant, integrations: Iterable[Integration] -) -> dict[str, JSON_TYPE]: + integrations: Iterable[Integration], +) -> dict[str, dict[str, Any]]: """Load condition files for multiple integrations.""" return { - integration.domain: _load_conditions_file(hass, integration) + integration.domain: { + get_absolute_description_key(integration.domain, key): value + for key, value in _load_conditions_file(integration).items() + } for integration in integrations } @@ -1137,7 +1153,7 @@ async def async_get_all_descriptions( return descriptions_cache # Files we loaded for missing descriptions - new_conditions_descriptions: dict[str, JSON_TYPE] = {} + new_conditions_descriptions: dict[str, dict[str, Any]] = {} # We try to avoid making a copy in the event the cache is good, # but now we must make a copy in case new conditions get added # while we are loading the missing ones so we do not @@ -1166,7 +1182,7 @@ async def async_get_all_descriptions( if integrations: new_conditions_descriptions = await hass.async_add_executor_job( - _load_conditions_files, hass, integrations + _load_conditions_files, integrations ) # Make a copy of the old cache and add missing descriptions to it @@ -1175,7 +1191,7 @@ async def async_get_all_descriptions( domain = conditions[missing_condition] if ( - yaml_description := new_conditions_descriptions.get(domain, {}).get( # type: ignore[union-attr] + yaml_description := new_conditions_descriptions.get(domain, {}).get( missing_condition ) ) is None: diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index da1c1c80619..c2ebddf8012 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -644,6 +644,13 @@ def slug(value: Any) -> str: raise vol.Invalid(f"invalid slug {value} (try {slg})") +def underscore_slug(value: Any) -> str: + """Validate value is a valid slug, possibly starting with an underscore.""" + if value.startswith("_"): + return f"_{slug(value[1:])}" + return slug(value) + + def schema_with_slug_keys( value_schema: dict | Callable, *, slug_validator: Callable[[Any], str] = slug ) -> Callable: diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 0b902ea4d23..5286daaeef0 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -268,11 +268,13 @@ class TargetStateChangeTracker: hass: HomeAssistant, selector_data: TargetSelectorData, action: Callable[[TargetStateChangedData], Any], + entity_filter: Callable[[set[str]], set[str]], ) -> None: """Initialize the state change tracker.""" self._hass = hass self._selector_data = selector_data self._action = action + self._entity_filter = entity_filter self._state_change_unsub: CALLBACK_TYPE | None = None self._registry_unsubs: list[CALLBACK_TYPE] = [] @@ -289,7 +291,9 @@ class TargetStateChangeTracker: self._hass, self._selector_data, expand_group=False ) - tracked_entities = selected.referenced.union(selected.indirectly_referenced) + tracked_entities = self._entity_filter( + selected.referenced.union(selected.indirectly_referenced) + ) @callback def state_change_listener(event: Event[EventStateChangedData]) -> None: @@ -348,6 +352,7 @@ def async_track_target_selector_state_change_event( hass: HomeAssistant, target_selector_config: ConfigType, action: Callable[[TargetStateChangedData], Any], + entity_filter: Callable[[set[str]], set[str]] = lambda x: x, ) -> CALLBACK_TYPE: """Track state changes for entities referenced directly or indirectly in a target selector.""" selector_data = TargetSelectorData(target_selector_config) @@ -355,5 +360,5 @@ def async_track_target_selector_state_change_event( raise HomeAssistantError( f"Target selector {target_selector_config} does not have any selectors defined" ) - tracker = TargetStateChangeTracker(hass, selector_data, action) + tracker = TargetStateChangeTracker(hass, selector_data, action, entity_filter) return tracker.async_setup() diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 85ee1e28309..8e3106093aa 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2030,7 +2030,7 @@ def apply(value, fn, *args, **kwargs): def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]: """Turn a macro with a 'returns' keyword argument into a function that returns what that argument is called with.""" - def wrapper(value, *args, **kwargs): + def wrapper(*args, **kwargs): return_value = None def returns(value): @@ -2039,7 +2039,7 @@ def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]: return value # Call the callable with the value and other args - macro(value, *args, **kwargs, returns=returns) + macro(*args, **kwargs, returns=returns) return return_value # Remove "macro_" from the macro's name to avoid confusion in the wrapper's name diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index de3f71c4834..741fac3fcf7 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -40,9 +40,9 @@ from homeassistant.loader import ( from homeassistant.util.async_ import create_eager_task from homeassistant.util.hass_dict import HassKey from homeassistant.util.yaml import load_yaml_dict -from homeassistant.util.yaml.loader import JSON_TYPE from . import config_validation as cv, selector +from .automation import get_absolute_description_key, get_relative_description_key from .integration_platform import async_process_integration_platforms from .selector import TargetSelector from .template import Template @@ -100,7 +100,7 @@ def starts_with_dot(key: str) -> str: _TRIGGERS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, starts_with_dot)): object, - cv.slug: vol.Any(None, _TRIGGER_SCHEMA), + cv.underscore_slug: vol.Any(None, _TRIGGER_SCHEMA), } ) @@ -139,6 +139,7 @@ async def _register_trigger_platform( if hasattr(platform, "async_get_triggers"): for trigger_key in await platform.async_get_triggers(hass): + trigger_key = get_absolute_description_key(integration_domain, trigger_key) hass.data[TRIGGERS][trigger_key] = integration_domain new_triggers.add(trigger_key) elif hasattr(platform, "async_validate_trigger_config") or hasattr( @@ -172,18 +173,18 @@ class Trigger(abc.ABC): @classmethod @abc.abstractmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" @abc.abstractmethod - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: - """Attach a trigger.""" + """Attach the trigger.""" class TriggerProtocol(Protocol): @@ -357,9 +358,8 @@ class PluggableAction: async def _async_get_trigger_platform( - hass: HomeAssistant, config: ConfigType -) -> TriggerProtocol: - trigger_key: str = config[CONF_PLATFORM] + hass: HomeAssistant, trigger_key: str +) -> tuple[str, TriggerProtocol]: platform_and_sub_type = trigger_key.split(".") platform = platform_and_sub_type[0] platform = _PLATFORM_ALIASES.get(platform, platform) @@ -368,7 +368,7 @@ async def _async_get_trigger_platform( except IntegrationNotFound: raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") from None try: - return await integration.async_get_platform("trigger") + return platform, await integration.async_get_platform("trigger") except ImportError: raise vol.Invalid( f"Integration '{platform}' does not provide trigger support" @@ -381,13 +381,16 @@ async def async_validate_trigger_config( """Validate triggers.""" config = [] for conf in trigger_config: - platform = await _async_get_trigger_platform(hass, conf) + trigger_key: str = conf[CONF_PLATFORM] + platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) if hasattr(platform, "async_get_triggers"): trigger_descriptors = await platform.async_get_triggers(hass) - trigger_key: str = conf[CONF_PLATFORM] - if not (trigger := trigger_descriptors.get(trigger_key)): + relative_trigger_key = get_relative_description_key( + platform_domain, trigger_key + ) + if not (trigger := trigger_descriptors.get(relative_trigger_key)): raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") - conf = await trigger.async_validate_trigger_config(hass, conf) + conf = await trigger.async_validate_config(hass, conf) elif hasattr(platform, "async_validate_trigger_config"): conf = await platform.async_validate_trigger_config(hass, conf) else: @@ -471,7 +474,8 @@ async def async_initialize_triggers( if not enabled: continue - platform = await _async_get_trigger_platform(hass, conf) + trigger_key: str = conf[CONF_PLATFORM] + platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) trigger_id = conf.get(CONF_ID, f"{idx}") trigger_idx = f"{idx}" trigger_alias = conf.get(CONF_ALIAS) @@ -487,8 +491,11 @@ async def async_initialize_triggers( action_wrapper = _trigger_action_wrapper(hass, action, conf) if hasattr(platform, "async_get_triggers"): trigger_descriptors = await platform.async_get_triggers(hass) - trigger = trigger_descriptors[conf[CONF_PLATFORM]](hass, conf) - coro = trigger.async_attach_trigger(action_wrapper, info) + relative_trigger_key = get_relative_description_key( + platform_domain, trigger_key + ) + trigger = trigger_descriptors[relative_trigger_key](hass, conf) + coro = trigger.async_attach(action_wrapper, info) else: coro = platform.async_attach_trigger(hass, conf, action_wrapper, info) @@ -525,11 +532,11 @@ async def async_initialize_triggers( return remove_triggers -def _load_triggers_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: +def _load_triggers_file(integration: Integration) -> dict[str, Any]: """Load triggers file for an integration.""" try: return cast( - JSON_TYPE, + dict[str, Any], _TRIGGERS_SCHEMA( load_yaml_dict(str(integration.file_path / "triggers.yaml")) ), @@ -549,11 +556,14 @@ def _load_triggers_file(hass: HomeAssistant, integration: Integration) -> JSON_T def _load_triggers_files( - hass: HomeAssistant, integrations: Iterable[Integration] -) -> dict[str, JSON_TYPE]: + integrations: Iterable[Integration], +) -> dict[str, dict[str, Any]]: """Load trigger files for multiple integrations.""" return { - integration.domain: _load_triggers_file(hass, integration) + integration.domain: { + get_absolute_description_key(integration.domain, key): value + for key, value in _load_triggers_file(integration).items() + } for integration in integrations } @@ -574,7 +584,7 @@ async def async_get_all_descriptions( return descriptions_cache # Files we loaded for missing descriptions - new_triggers_descriptions: dict[str, JSON_TYPE] = {} + new_triggers_descriptions: dict[str, dict[str, Any]] = {} # We try to avoid making a copy in the event the cache is good, # but now we must make a copy in case new triggers get added # while we are loading the missing ones so we do not @@ -601,7 +611,7 @@ async def async_get_all_descriptions( if integrations: new_triggers_descriptions = await hass.async_add_executor_job( - _load_triggers_files, hass, integrations + _load_triggers_files, integrations ) # Make a copy of the old cache and add missing descriptions to it @@ -610,7 +620,7 @@ async def async_get_all_descriptions( domain = triggers[missing_trigger] if ( - yaml_description := new_triggers_descriptions.get(domain, {}).get( # type: ignore[union-attr] + yaml_description := new_triggers_descriptions.get(domain, {}).get( missing_trigger ) ) is None: diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 6b566797017..16f3b9b6964 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -92,7 +92,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): frame.report_usage( "relies on ContextVar, but should pass the config entry explicitly.", core_behavior=frame.ReportBehavior.ERROR, - custom_integration_behavior=frame.ReportBehavior.LOG, + custom_integration_behavior=frame.ReportBehavior.IGNORE, breaks_in_ha_version="2026.8", ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f8a57ba61bb..28e7491c48c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,11 +34,11 @@ dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==4.0.1 -hass-nabucasa==0.110.1 +habluetooth==4.0.2 +hass-nabucasa==0.111.1 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250731.0 +home-assistant-frontend==20250806.0 home-assistant-intents==2025.7.30 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pyproject.toml b/pyproject.toml index a32e9308fe2..0125d5b1bbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.110.1", + "hass-nabucasa==0.111.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index ba08a72e324..af9a835e0d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.110.1 +hass-nabucasa==0.111.1 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1c7280547c7..3edf7b654ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.68.2 +PySwitchbot==0.68.3 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.1 +aioautomower==2.1.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -453,7 +453,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.1 +airos==0.2.4 # homeassistant.components.airthings_ble airthings-ble==0.9.2 @@ -573,7 +573,7 @@ av==13.1.0 # avion==0.10 # homeassistant.components.axis -axis==64 +axis==65 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 @@ -777,7 +777,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.5.0 +deebot-client==13.6.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -1130,10 +1130,10 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.1 # homeassistant.components.bluetooth -habluetooth==4.0.1 +habluetooth==4.0.2 # homeassistant.components.cloud -hass-nabucasa==0.110.1 +hass-nabucasa==0.111.1 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1171,10 +1171,10 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.77 +holidays==0.78 # homeassistant.components.frontend -home-assistant-frontend==20250731.0 +home-assistant-frontend==20250806.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 @@ -1216,7 +1216,7 @@ ibmiotf==0.3.4 ical==11.0.0 # homeassistant.components.caldav -icalendar==6.1.0 +icalendar==6.3.1 # homeassistant.components.ping icmplib==3.0 @@ -1252,7 +1252,7 @@ influxdb-client==1.48.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.16.2 +inkbird-ble==1.1.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1307,7 +1307,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.7.23.50952 +knx-frontend==2025.8.6.52906 # homeassistant.components.konnected konnected==1.2.0 @@ -1340,7 +1340,7 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.5.0 +letpot==0.6.1 # homeassistant.components.foscam libpyfoscamcgi==0.0.6 @@ -1628,7 +1628,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.12.4 +opower==0.15.1 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -2155,7 +2155,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.9.2 +pymodbus==3.11.0 # homeassistant.components.monoprice pymonoprice==0.4 @@ -2666,7 +2666,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.5 +reolink-aio==0.14.6 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2805,7 +2805,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.9 +soco==0.30.11 # homeassistant.components.solaredge_local solaredge-local==0.2.3 @@ -3133,7 +3133,7 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.7.1 +wyoming==1.7.2 # homeassistant.components.xbox xbox-webapi==2.1.0 @@ -3167,7 +3167,7 @@ yalexs-ble==3.1.2 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.10.0 +yalexs==8.11.1 # homeassistant.components.yeelight yeelight==0.7.16 @@ -3203,7 +3203,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.65 +zha==0.0.67 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test.txt b/requirements_test.txt index 6c0fc02df58..592d4758340 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.3 mock-open==1.4.0 -mypy-dev==1.18.0a3 +mypy-dev==1.18.0a4 pre-commit==4.2.0 pydantic==2.11.7 pylint==3.3.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4aa7a5063e6..a75ee0920f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.68.2 +PySwitchbot==0.68.3 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.1 +aioautomower==2.1.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -435,7 +435,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.1 +airos==0.2.4 # homeassistant.components.airthings_ble airthings-ble==0.9.2 @@ -522,7 +522,7 @@ automower-ble==0.2.7 av==13.1.0 # homeassistant.components.axis -axis==64 +axis==65 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 @@ -677,7 +677,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.5.0 +deebot-client==13.6.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -991,10 +991,10 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.1 # homeassistant.components.bluetooth -habluetooth==4.0.1 +habluetooth==4.0.2 # homeassistant.components.cloud -hass-nabucasa==0.110.1 +hass-nabucasa==0.111.1 # homeassistant.components.assist_satellite # homeassistant.components.conversation @@ -1020,10 +1020,10 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.77 +holidays==0.78 # homeassistant.components.frontend -home-assistant-frontend==20250731.0 +home-assistant-frontend==20250806.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 @@ -1056,7 +1056,7 @@ ibeacon-ble==1.2.0 ical==11.0.0 # homeassistant.components.caldav -icalendar==6.1.0 +icalendar==6.3.1 # homeassistant.components.ping icmplib==3.0 @@ -1086,7 +1086,7 @@ influxdb-client==1.48.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.16.2 +inkbird-ble==1.1.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1129,7 +1129,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.7.23.50952 +knx-frontend==2025.8.6.52906 # homeassistant.components.konnected konnected==1.2.0 @@ -1159,7 +1159,7 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.5.0 +letpot==0.6.1 # homeassistant.components.foscam libpyfoscamcgi==0.0.6 @@ -1384,7 +1384,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.12.4 +opower==0.15.1 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1794,7 +1794,7 @@ pymiele==0.5.2 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.9.2 +pymodbus==3.11.0 # homeassistant.components.monoprice pymonoprice==0.4 @@ -2212,7 +2212,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.5 +reolink-aio==0.14.6 # homeassistant.components.rflink rflink==0.0.67 @@ -2315,7 +2315,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.9 +soco==0.30.11 # homeassistant.components.solarlog solarlog_cli==0.4.0 @@ -2586,7 +2586,7 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.7.1 +wyoming==1.7.2 # homeassistant.components.xbox xbox-webapi==2.1.0 @@ -2617,7 +2617,7 @@ yalexs-ble==3.1.2 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.10.0 +yalexs==8.11.1 # homeassistant.components.yeelight yeelight==0.7.16 @@ -2647,7 +2647,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.65 +zha==0.0.67 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 diff --git a/script/hassfest/conditions.py b/script/hassfest/conditions.py index 0cc16226422..3d9b2036230 100644 --- a/script/hassfest/conditions.py +++ b/script/hassfest/conditions.py @@ -50,7 +50,7 @@ CONDITION_SCHEMA = vol.Any( CONDITIONS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, condition.starts_with_dot)): object, - cv.slug: CONDITION_SCHEMA, + cv.underscore_slug: CONDITION_SCHEMA, } ) diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index 79ad7eec5ff..6d2187e3fe6 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -126,7 +126,7 @@ CONDITION_ICONS_SCHEMA = cv.schema_with_slug_keys( vol.Optional("condition"): icon_value_validator, } ), - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ) @@ -136,7 +136,7 @@ TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys( vol.Optional("trigger"): icon_value_validator, } ), - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 974c932ae5c..d09fb27f71a 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -434,7 +434,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: slug_validator=translation_key_validator, ), }, - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ), vol.Optional("triggers"): cv.schema_with_slug_keys( { @@ -450,7 +450,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: slug_validator=translation_key_validator, ), }, - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ), vol.Optional("conversation"): { vol.Required("agent"): { diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py index 8efaab47050..7406e6f98ea 100644 --- a/script/hassfest/triggers.py +++ b/script/hassfest/triggers.py @@ -50,7 +50,7 @@ TRIGGER_SCHEMA = vol.Any( TRIGGERS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, trigger.starts_with_dot)): object, - cv.slug: TRIGGER_SCHEMA, + cv.underscore_slug: TRIGGER_SCHEMA, } ) diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 65bc35a5ff8..e5d3cf04a37 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -2,7 +2,7 @@ import asyncio from typing import Any -from unittest.mock import patch +from unittest.mock import PropertyMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -300,6 +300,20 @@ async def test_loading_does_not_write_right_away( assert hass_storage[auth_store.STORAGE_KEY] != {} +async def test_duplicate_uuid( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test we don't override user if we have a duplicate user ID.""" + hass_storage[auth_store.STORAGE_KEY] = MOCK_STORAGE_DATA + store = auth_store.AuthStore(hass) + await store.async_load() + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex_mock: + hex_mock.side_effect = ["user-id", "new-id"] + user = await store.async_create_user("Test User") + assert len(hex_mock.mock_calls) == 2 + assert user.id == "new-id" + + async def test_add_remove_user_affects_tokens( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: diff --git a/tests/components/airos/snapshots/test_sensor.ambr b/tests/components/airos/snapshots/test_sensor.ambr index a92d2dc35a2..e414d35beb2 100644 --- a/tests/components/airos/snapshots/test_sensor.ambr +++ b/tests/components/airos/snapshots/test_sensor.ambr @@ -439,64 +439,6 @@ 'state': '5500', }) # --- -# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'ap_ptp', - 'sta_ptp', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wireless mode', - 'platform': 'airos', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wireless_mode', - 'unique_id': '01:23:45:67:89:AB_wireless_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'NanoStation 5AC ap name Wireless mode', - 'options': list([ - 'ap_ptp', - 'sta_ptp', - ]), - }), - 'context': , - 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'ap_ptp', - }) -# --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 9d2a6376732..212c80dfc2b 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -4,9 +4,9 @@ from typing import Any from unittest.mock import AsyncMock from airos.exceptions import ( - ConnectionAuthenticationError, - DeviceConnectionError, - KeyDataMissingError, + AirOSConnectionAuthenticationError, + AirOSDeviceConnectionError, + AirOSKeyDataMissingError, ) import pytest @@ -78,9 +78,9 @@ async def test_form_duplicate_entry( @pytest.mark.parametrize( ("exception", "error"), [ - (ConnectionAuthenticationError, "invalid_auth"), - (DeviceConnectionError, "cannot_connect"), - (KeyDataMissingError, "key_data_missing"), + (AirOSConnectionAuthenticationError, "invalid_auth"), + (AirOSDeviceConnectionError, "cannot_connect"), + (AirOSKeyDataMissingError, "key_data_missing"), (Exception, "unknown"), ], ) diff --git a/tests/components/airos/test_sensor.py b/tests/components/airos/test_sensor.py index 561741b1a2b..c9e675e7987 100644 --- a/tests/components/airos/test_sensor.py +++ b/tests/components/airos/test_sensor.py @@ -4,9 +4,9 @@ from datetime import timedelta from unittest.mock import AsyncMock from airos.exceptions import ( - ConnectionAuthenticationError, - DataMissingError, - DeviceConnectionError, + AirOSConnectionAuthenticationError, + AirOSDataMissingError, + AirOSDeviceConnectionError, ) from freezegun.api import FrozenDateTimeFactory import pytest @@ -39,10 +39,10 @@ async def test_all_entities( @pytest.mark.parametrize( ("exception"), [ - ConnectionAuthenticationError, + AirOSConnectionAuthenticationError, TimeoutError, - DeviceConnectionError, - DataMissingError, + AirOSDeviceConnectionError, + AirOSDataMissingError, ], ) async def test_sensor_update_exception_handling( diff --git a/tests/components/amberelectric/test_services.py b/tests/components/amberelectric/test_services.py index 7ef895a5d88..bfff432b18c 100644 --- a/tests/components/amberelectric/test_services.py +++ b/tests/components/amberelectric/test_services.py @@ -6,10 +6,8 @@ import pytest import voluptuous as vol from homeassistant.components.amberelectric.const import DOMAIN, SERVICE_GET_FORECASTS -from homeassistant.components.amberelectric.services import ( - ATTR_CHANNEL_TYPE, - ATTR_CONFIG_ENTRY_ID, -) +from homeassistant.components.amberelectric.services import ATTR_CHANNEL_TYPE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index be4f41ad4cd..ff54539bb39 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -1,5 +1,6 @@ """Tests for the Anthropic integration.""" +from typing import Any from unittest.mock import patch from anthropic import ( @@ -12,9 +13,12 @@ from httpx import URL, Request, Response import pytest from homeassistant.components.anthropic.const import DOMAIN -from homeassistant.config_entries import ConfigSubentryData +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -114,7 +118,7 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -149,6 +153,207 @@ async def test_migration_from_v1_to_v2( } +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Claude", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Claude 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="claude", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="claude", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 2 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "Claude conversation" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Claude" in subentry.title + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + async def test_migration_from_v1_to_v2_with_multiple_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -226,7 +431,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -320,7 +525,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -443,7 +648,7 @@ async def test_migration_from_v2_1_to_v2_2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == "Claude" assert len(entry.subentries) == 2 @@ -500,3 +705,193 @@ async def test_migration_from_v2_1_to_v2_2( assert device.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 3, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 2, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_to_v2_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration to version 2.3.""" + # Create a v2.2 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + disabled_by=config_entry_disabled_by, + version=2, + minor_version=2, + subentries_data=[ + { + "data": { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + }, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": "Claude haiku", + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="claude", + ) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 + assert len(mock_config_entry.subentries) == 1 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index 856d9e6e8a0..e099b9c24e4 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -4,13 +4,9 @@ from unittest.mock import AsyncMock, MagicMock, Mock import pytest -from homeassistant.components.blink.const import ( - ATTR_CONFIG_ENTRY_ID, - DOMAIN, - SERVICE_SEND_PIN, -) +from homeassistant.components.blink.const import DOMAIN, SERVICE_SEND_PIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PIN +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError diff --git a/tests/components/bosch_alarm/test_services.py b/tests/components/bosch_alarm/test_services.py index 7b5088f32c3..059b01c1e3b 100644 --- a/tests/components/bosch_alarm/test_services.py +++ b/tests/components/bosch_alarm/test_services.py @@ -9,11 +9,11 @@ import pytest import voluptuous as vol from homeassistant.components.bosch_alarm.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME, ) +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component diff --git a/tests/components/downloader/conftest.py b/tests/components/downloader/conftest.py new file mode 100644 index 00000000000..3bb63455ccc --- /dev/null +++ b/tests/components/downloader/conftest.py @@ -0,0 +1,94 @@ +"""Provide common fixtures for downloader tests.""" + +import asyncio +from pathlib import Path + +import pytest +from requests_mock import Mocker + +from homeassistant.components.downloader.const import ( + CONF_DOWNLOAD_DIR, + DOMAIN, + DOWNLOAD_COMPLETED_EVENT, + DOWNLOAD_FAILED_EVENT, +) +from homeassistant.core import Event, HomeAssistant, callback + +from tests.common import MockConfigEntry + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up the downloader integration for testing.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + download_dir: Path, +) -> MockConfigEntry: + """Return a mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DOWNLOAD_DIR: str(download_dir)}, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def download_dir(tmp_path: Path) -> Path: + """Return a download directory.""" + return tmp_path + + +@pytest.fixture(autouse=True) +def mock_download_request( + requests_mock: Mocker, + download_url: str, +) -> None: + """Mock the download request.""" + requests_mock.get(download_url, text="{'one': 1}") + + +@pytest.fixture +def download_url() -> str: + """Return a mock download URL.""" + return "http://example.com/file.txt" + + +@pytest.fixture +def download_completed(hass: HomeAssistant) -> asyncio.Event: + """Return an asyncio event to wait for download completion.""" + download_event = asyncio.Event() + + @callback + def download_set(event: Event[dict[str, str]]) -> None: + """Set the event when download is completed.""" + download_event.set() + + hass.bus.async_listen_once(f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}", download_set) + + return download_event + + +@pytest.fixture +def download_failed(hass: HomeAssistant) -> asyncio.Event: + """Return an asyncio event to wait for download failure.""" + download_event = asyncio.Event() + + @callback + def download_set(event: Event[dict[str, str]]) -> None: + """Set the event when download has failed.""" + download_event.set() + + hass.bus.async_listen_once(f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", download_set) + + return download_event diff --git a/tests/components/downloader/test_init.py b/tests/components/downloader/test_init.py index e74eb376b39..fe001838afe 100644 --- a/tests/components/downloader/test_init.py +++ b/tests/components/downloader/test_init.py @@ -1,6 +1,8 @@ """Tests for the downloader component init.""" -from unittest.mock import patch +from pathlib import Path + +import pytest from homeassistant.components.downloader.const import ( CONF_DOWNLOAD_DIR, @@ -13,17 +15,57 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_initialization(hass: HomeAssistant) -> None: - """Test the initialization of the downloader component.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_DOWNLOAD_DIR: "/test_dir", - }, - ) - config_entry.add_to_hass(hass) - with patch("os.path.isdir", return_value=True): - assert await hass.config_entries.async_setup(config_entry.entry_id) +@pytest.fixture +def download_dir(tmp_path: Path, request: pytest.FixtureRequest) -> Path: + """Return a download directory.""" + if hasattr(request, "param"): + return tmp_path / request.param + return tmp_path + + +async def test_config_entry_setup( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """Test config entry setup.""" + config_entry = setup_integration assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) assert config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_setup_relative_directory( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test config entry setup with a relative download directory.""" + relative_directory = "downloads" + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_DOWNLOAD_DIR: relative_directory}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # The config entry will fail to set up since the directory does not exist. + # This is not relevant for this test. + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.data[CONF_DOWNLOAD_DIR] == hass.config.path( + relative_directory + ) + + +@pytest.mark.parametrize( + "download_dir", + [ + "not_existing_path", + ], + indirect=True, +) +async def test_config_entry_setup_not_existing_directory( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry setup without existing download directory.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert not hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/downloader/test_services.py b/tests/components/downloader/test_services.py new file mode 100644 index 00000000000..fbdc088021a --- /dev/null +++ b/tests/components/downloader/test_services.py @@ -0,0 +1,54 @@ +"""Test downloader services.""" + +import asyncio +from contextlib import AbstractContextManager, nullcontext as does_not_raise + +import pytest + +from homeassistant.components.downloader.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize( + ("subdir", "expected_result"), + [ + ("test", does_not_raise()), + ("test/path", does_not_raise()), + ("~test/path", pytest.raises(ServiceValidationError)), + ("~/../test/path", pytest.raises(ServiceValidationError)), + ("../test/path", pytest.raises(ServiceValidationError)), + (".../test/path", pytest.raises(ServiceValidationError)), + ("/test/path", pytest.raises(ServiceValidationError)), + ], +) +async def test_download_invalid_subdir( + hass: HomeAssistant, + download_completed: asyncio.Event, + download_failed: asyncio.Event, + download_url: str, + subdir: str, + expected_result: AbstractContextManager, +) -> None: + """Test service invalid subdirectory.""" + + async def call_service() -> None: + """Call the download service.""" + completed = hass.async_create_task(download_completed.wait()) + failed = hass.async_create_task(download_failed.wait()) + await hass.services.async_call( + DOMAIN, + "download_file", + { + "url": download_url, + "subdir": subdir, + "filename": "file.txt", + "overwrite": True, + }, + blocking=True, + ) + await asyncio.wait((completed, failed), return_when=asyncio.FIRST_COMPLETED) + + with expected_result: + await call_service() diff --git a/tests/components/hassio/test_config.py b/tests/components/hassio/test_config.py index 4df8d2e81ac..4cdea02b087 100644 --- a/tests/components/hassio/test_config.py +++ b/tests/components/hassio/test_config.py @@ -1,13 +1,16 @@ """Test websocket API.""" +from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, patch -from uuid import UUID +from uuid import UUID, uuid4 import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.auth.models import User +from homeassistant.components.hassio import HASSIO_USER_NAME from homeassistant.components.hassio.const import DATA_CONFIG_STORE, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -98,7 +101,24 @@ def mock_all( ) -@pytest.mark.usefixtures("hassio_env") +@pytest.fixture +def mock_hassio_user_id() -> Generator[None]: + """Mock the HASSIO user ID for snapshot testing.""" + original_user_init = User.__init__ + + def mock_user_init(self, *args, **kwargs): + with patch("homeassistant.auth.models.uuid.uuid4") as mock_uuid: + if kwargs.get("name") == HASSIO_USER_NAME: + mock_uuid.return_value = UUID(bytes=b"very_very_random", version=4) + else: + mock_uuid.return_value = uuid4() + original_user_init(self, *args, **kwargs) + + with patch.object(User, "__init__", mock_user_init): + yield + + +@pytest.mark.usefixtures("hassio_env", "mock_hassio_user_id") @pytest.mark.parametrize( "storage_data", [ @@ -151,10 +171,7 @@ async def test_load_config_store( await hass.auth.async_create_refresh_token(user) await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN]) - with ( - patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), - patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), - ): + with patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0): assert await async_setup_component(hass, "hassio", {}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -162,7 +179,7 @@ async def test_load_config_store( assert hass.data[DATA_CONFIG_STORE].data.to_dict() == snapshot -@pytest.mark.usefixtures("hassio_env") +@pytest.mark.usefixtures("hassio_env", "mock_hassio_user_id") async def test_save_config_store( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -171,10 +188,7 @@ async def test_save_config_store( snapshot: SnapshotAssertion, ) -> None: """Test saving the config store.""" - with ( - patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), - patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), - ): + with patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0): assert await async_setup_component(hass, "hassio", {}) await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/components/history_stats/test_config_flow.py b/tests/components/history_stats/test_config_flow.py index a1f0a080b8a..08dbefe7465 100644 --- a/tests/components/history_stats/test_config_flow.py +++ b/tests/components/history_stats/test_config_flow.py @@ -400,10 +400,10 @@ async def test_options_flow_preview( msg = await client.receive_json() assert msg["event"]["state"] == exp_count - hass.states.async_set(monitored_entity, "on") + hass.states.async_set(monitored_entity, "on") - msg = await client.receive_json() - assert msg["event"]["state"] == "3" + msg = await client.receive_json() + assert msg["event"]["state"] == "3" async def test_options_flow_preview_errors( diff --git a/tests/components/husqvarna_automower/snapshots/test_button.ambr b/tests/components/husqvarna_automower/snapshots/test_button.ambr index 3d48125aa9a..058fc214a91 100644 --- a/tests/components/husqvarna_automower/snapshots/test_button.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_button.ambr @@ -47,6 +47,54 @@ 'state': 'unavailable', }) # --- +# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_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': 'button', + 'entity_category': None, + 'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time', + '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 cutting blade usage time', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_cutting_blade_usage_time', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_reset_cutting_blade_usage_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Reset cutting blade usage time', + }), + 'context': , + 'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_button_snapshot[button.test_mower_1_sync_clock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index 9fb5ad28c89..dcb4252ac8e 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -28,7 +28,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat @pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC)) -async def test_button_states_and_commands( +async def test_button_error_confirm( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, @@ -58,42 +58,43 @@ async def test_button_states_and_commands( state = hass.states.get(entity_id) assert state.state == STATE_UNKNOWN - await hass.services.async_call( - domain="button", - service=SERVICE_PRESS, - target={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_automower_client.commands.error_confirm.assert_called_once_with(TEST_MOWER_ID) - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == "2023-06-05T00:16:00+00:00" - mock_automower_client.commands.error_confirm.side_effect = ApiError("Test error") - with pytest.raises( - HomeAssistantError, - match="Failed to send command: Test error", - ): - await hass.services.async_call( - domain="button", - service=SERVICE_PRESS, - target={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - +@pytest.mark.parametrize( + ("entity_id", "name", "expected_command"), + [ + ( + "button.test_mower_1_confirm_error", + "Test Mower 1 Confirm error", + "error_confirm", + ), + ( + "button.test_mower_1_sync_clock", + "Test Mower 1 Sync clock", + "set_datetime", + ), + ( + "button.test_mower_1_reset_cutting_blade_usage_time", + "Test Mower 1 Reset cutting blade usage time", + "reset_cutting_blade_usage_time", + ), + ], +) @pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC)) -async def test_sync_clock( +async def test_button_commands( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, values: dict[str, MowerAttributes], + entity_id: str, + name: str, + expected_command: str, ) -> None: - """Test sync clock button command.""" - entity_id = "button.test_mower_1_sync_clock" + """Test Automower button commands.""" + values[TEST_MOWER_ID].mower.is_error_confirmable = True await setup_integration(hass, mock_config_entry) + state = hass.states.get(entity_id) - assert state.name == "Test Mower 1 Sync clock" + assert state.name == name mock_automower_client.get_status.return_value = values @@ -103,11 +104,15 @@ async def test_sync_clock( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mock_automower_client.commands.set_datetime.assert_called_once_with(TEST_MOWER_ID) + + command_mock = getattr(mock_automower_client.commands, expected_command) + command_mock.assert_called_once_with(TEST_MOWER_ID) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2024-02-29T11:00:00+00:00" - mock_automower_client.commands.set_datetime.side_effect = ApiError("Test error") + command_mock.reset_mock() + command_mock.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 81874cea8a7..a157380ab3c 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -14,7 +14,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import Calendar, MowerAttributes, WorkArea +from aioautomower.model import Calendar, MowerAttributes, MowerStates, WorkArea from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -484,3 +484,212 @@ async def test_add_and_remove_work_area( - ADDITIONAL_NUMBER_ENTITIES - ADDITIONAL_SENSOR_ENTITIES ) + + +@pytest.mark.parametrize( + ("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"), + [ + (True, MowerStates.OFF, False, MowerStates.OFF), # False + (False, MowerStates.PAUSED, False, MowerStates.OFF), # False + (False, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.PAUSED), # False + (True, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.OFF), # False + ], +) +async def test_dynamic_polling( + hass: HomeAssistant, + mock_automower_client, + mock_config_entry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], + mower1_connected: bool, + mower1_state: MowerStates, + mower2_connected: bool, + mower2_state: MowerStates, +) -> None: + """Test that the ws_ready_callback triggers an attempt to start the Watchdog task. + + and that the pong callback stops polling when all mowers are inactive. + """ + websocket_values = deepcopy(values) + poll_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["data_cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) + + @callback + def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: + callback_holder["ws_ready_cb"] = cb + + mock_automower_client.register_ws_ready_callback.side_effect = ( + fake_register_ws_ready_callback + ) + + await setup_integration(hass, mock_config_entry) + + assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered" + callback_holder["ws_ready_cb"]() + + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + # websocket is still active, but mowers are inactive -> no polling required + poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected + poll_values[TEST_MOWER_ID].mower.state = mower1_state + poll_values["1234"].metadata.connected = mower2_connected + poll_values["1234"].mower.state = mower2_state + + mock_automower_client.get_status.return_value = poll_values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 3 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + # websocket is still active, and mowers are active -> polling required + mock_automower_client.get_status.reset_mock() + assert mock_automower_client.get_status.call_count == 0 + poll_values[TEST_MOWER_ID].metadata.connected = True + poll_values[TEST_MOWER_ID].mower.state = MowerStates.PAUSED + poll_values["1234"].metadata.connected = False + poll_values["1234"].mower.state = MowerStates.OFF + websocket_values = deepcopy(poll_values) + callback_holder["data_cb"](websocket_values) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + +@pytest.mark.parametrize( + ("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"), + [ + (True, MowerStates.OFF, False, MowerStates.OFF), # False + (False, MowerStates.PAUSED, False, MowerStates.OFF), # False + (False, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.PAUSED), # False + (True, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.OFF), # False + ], +) +async def test_websocket_watchdog( + hass: HomeAssistant, + mock_automower_client, + mock_config_entry, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + values: dict[str, MowerAttributes], + mower1_connected: bool, + mower1_state: MowerStates, + mower2_connected: bool, + mower2_state: MowerStates, +) -> None: + """Test that the ws_ready_callback triggers an attempt to start the Watchdog task. + + and that the pong callback stops polling when all mowers are inactive. + """ + poll_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["data_cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) + + @callback + def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: + callback_holder["ws_ready_cb"] = cb + + mock_automower_client.register_ws_ready_callback.side_effect = ( + fake_register_ws_ready_callback + ) + + await setup_integration(hass, mock_config_entry) + + assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered" + callback_holder["ws_ready_cb"]() + + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + # websocket is still active, but mowers are inactive -> no polling required + poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected + poll_values[TEST_MOWER_ID].mower.state = mower1_state + poll_values["1234"].metadata.connected = mower2_connected + poll_values["1234"].mower.state = mower2_state + + mock_automower_client.get_status.return_value = poll_values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 3 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + # Simulate Pong loss and reset mock -> polling required + mock_automower_client.send_empty_message.return_value = False + mock_automower_client.get_status.reset_mock() + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 0 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index 6e73bb430cf..d8be422899a 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -6,6 +6,7 @@ from letpot.models import ( AuthenticationInfo, LetPotDeviceErrors, LetPotDeviceStatus, + LightMode, TemperatureUnit, ) @@ -33,7 +34,7 @@ AUTHENTICATION = AuthenticationInfo( MAX_STATUS = LetPotDeviceStatus( errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False), light_brightness=500, - light_mode=1, + light_mode=LightMode.VEGETABLE, light_schedule_end=datetime.time(18, 0), light_schedule_start=datetime.time(8, 0), online=True, @@ -53,7 +54,7 @@ MAX_STATUS = LetPotDeviceStatus( SE_STATUS = LetPotDeviceStatus( errors=LetPotDeviceErrors(low_water=True, pump_malfunction=True), light_brightness=500, - light_mode=1, + light_mode=LightMode.VEGETABLE, light_schedule_end=datetime.time(18, 0), light_schedule_start=datetime.time(8, 0), online=True, diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 6d59f8bd2ef..03ce2ec4a0d 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -44,10 +44,15 @@ def _mock_device_info(device_type: str) -> LetPotDeviceInfo: def _mock_device_features(device_type: str) -> DeviceFeature: """Return mock device feature support for the given type.""" if device_type == "LPH31": - return DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH | DeviceFeature.PUMP_STATUS + return ( + DeviceFeature.CATEGORY_HYDROPONIC_GARDEN + | DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH + | DeviceFeature.PUMP_STATUS + ) if device_type == "LPH63": return ( - DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + DeviceFeature.CATEGORY_HYDROPONIC_GARDEN + | DeviceFeature.LIGHT_BRIGHTNESS_LEVELS | DeviceFeature.NUTRIENT_BUTTON | DeviceFeature.PUMP_AUTO | DeviceFeature.PUMP_STATUS diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index f51d39f8687..b08f886422f 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -6,7 +6,6 @@ from mastodon.Mastodon import MastodonAPIError, MediaAttachment import pytest from homeassistant.components.mastodon.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_CONTENT_WARNING, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, @@ -15,6 +14,7 @@ from homeassistant.components.mastodon.const import ( DOMAIN, ) from homeassistant.components.mastodon.services import SERVICE_POST +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/matter/fixtures/nodes/pump.json b/tests/components/matter/fixtures/nodes/pump.json index e4afc0b4f33..6d74b3d1b89 100644 --- a/tests/components/matter/fixtures/nodes/pump.json +++ b/tests/components/matter/fixtures/nodes/pump.json @@ -203,7 +203,7 @@ "1/6/65528": [], "1/6/65529": [0, 1, 2], "1/6/65531": [0, 65532, 65533, 65528, 65529, 65531], - "1/8/0": 254, + "1/8/0": 200, "1/8/15": 0, "1/8/17": 0, "1/8/65532": 0, diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index f7f467b4ed0..24a92799082 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -2189,7 +2189,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '127.0', + 'state': '100.0', }) # --- # name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-entry] diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index b59e6848f63..d35a889a436 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -172,7 +172,7 @@ async def test_pump_level( # CurrentLevel on LevelControl cluster state = hass.states.get("number.mock_pump_setpoint") assert state - assert state.state == "127.0" + assert state.state == "100.0" set_node_attribute(matter_node, 1, 8, 0, 100) await trigger_subscription_callback(hass, matter_client) diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 2ced94a7399..8c5d073e3e9 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -14,7 +14,6 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.mealie.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_END_DATE, ATTR_ENTRY_TYPE, ATTR_INCLUDE_TAGS, @@ -35,7 +34,7 @@ from homeassistant.components.mealie.services import ( SERVICE_SET_MEALPLAN, SERVICE_SET_RANDOM_MEALPLAN, ) -from homeassistant.const import ATTR_DATE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index ba404e2dff0..77b90403823 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -32,6 +32,7 @@ from homeassistant.components.vacuum import ( from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from .common import ( help_custom_config, @@ -108,7 +109,7 @@ async def test_default_supported_features( entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( - ["start", "stop", "return_home", "battery", "clean_spot"] + ["start", "stop", "return_home", "clean_spot"] ) @@ -313,8 +314,6 @@ async def test_status( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") assert state.state == VacuumActivity.CLEANING - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" assert state.attributes.get(ATTR_FAN_SPEED) == "max" message = """{ @@ -326,8 +325,6 @@ async def test_status( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") assert state.state == VacuumActivity.DOCKED - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 assert state.attributes.get(ATTR_FAN_SPEED) == "min" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == ["min", "medium", "high", "max"] @@ -337,6 +334,69 @@ async def test_status( assert state.state == STATE_UNKNOWN +# Use of the battery feature was deprecated in HA Core 2025.8 +# and will be removed with HA Core 2026.2 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ({mqttvacuum.CONF_SUPPORTED_FEATURES: ["battery"]},), + ) + ], +) +async def test_status_with_deprecated_battery_feature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test status updates from the vacuum with deprecated battery feature.""" + await mqtt_mock_entry() + state = hass.states.get("vacuum.mqtttest") + assert state.state == STATE_UNKNOWN + + message = """{ + "battery_level": 54, + "state": "cleaning" + }""" + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == VacuumActivity.CLEANING + assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 + assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" + + message = """{ + "battery_level": 61, + "state": "docked" + }""" + + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == VacuumActivity.DOCKED + assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" + assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 + + message = '{"state":null}' + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == STATE_UNKNOWN + assert ( + "MQTT vacuum entity vacuum.mqtttest implements " + "the battery feature which is deprecated." in caplog.text + ) + + # assert a repair issue was created for the entity + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + mqtt.DOMAIN, "deprecated_vacuum_battery_feature_vacuum.mqtttest" + ) + assert issue is not None + assert issue.issue_domain == "vacuum" + assert issue.translation_key == "deprecated_vacuum_battery_feature" + assert issue.translation_placeholders == {"entity_id": "vacuum.mqtttest"} + + @pytest.mark.parametrize( "hass_config", [ @@ -346,7 +406,9 @@ async def test_status( ( { mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - mqttvacuum.DEFAULT_SERVICES, SERVICE_TO_STRING + mqttvacuum.DEFAULT_SERVICES + | vacuum.VacuumEntityFeature.BATTERY, + SERVICE_TO_STRING, ) }, ), diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py index c13ea342262..27253ae2b20 100644 --- a/tests/components/music_assistant/test_actions.py +++ b/tests/components/music_assistant/test_actions.py @@ -11,12 +11,12 @@ from homeassistant.components.music_assistant.actions import ( SERVICE_SEARCH, ) from homeassistant.components.music_assistant.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_MEDIA_TYPE, ATTR_SEARCH_NAME, DOMAIN, ) +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from .common import create_library_albums_from_fixture, setup_integration_from_fixtures diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index 1db57302704..766de8a7d6d 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -1,5 +1,6 @@ """Tests for the Ollama integration.""" +from typing import Any from unittest.mock import patch from httpx import ConnectError @@ -7,9 +8,12 @@ import pytest from homeassistant.components import ollama from homeassistant.components.ollama.const import DOMAIN -from homeassistant.config_entries import ConfigSubentryData +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er, llm +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component from . import TEST_OPTIONS @@ -96,7 +100,7 @@ async def test_migration_from_v1( await hass.async_block_till_done() assert mock_config_entry.version == 3 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 # After migration, parent entry should only have URL assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} assert mock_config_entry.options == {} @@ -223,7 +227,7 @@ async def test_migration_from_v1_with_multiple_urls( for idx, entry in enumerate(entries): assert entry.version == 3 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert len(entry.subentries) == 2 @@ -332,7 +336,7 @@ async def test_migration_from_v1_with_same_urls( entry = entries[0] assert entry.version == 3 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options # Two conversation subentries from the two original entries and 1 aitask subentry assert len(entry.subentries) == 3 @@ -365,6 +369,209 @@ async def test_migration_from_v1_with_same_urls( } +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=V1_TEST_OPTIONS, + version=1, + title="Ollama", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=V1_TEST_OPTIONS, + version=1, + title="Ollama 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="ollama", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 3 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "Ollama" + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == {"model": "llama3.2:latest", **V1_TEST_OPTIONS} + assert "Ollama" in subentry.title + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == {"model": "llama3.2:latest"} + assert ai_task_subentries[0].title == "Ollama AI Task" + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -457,7 +664,7 @@ async def test_migration_from_v2_1( assert len(entries) == 1 entry = entries[0] assert entry.version == 3 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == "Ollama" assert len(entry.subentries) == 3 @@ -546,7 +753,7 @@ async def test_migration_from_v2_2(hass: HomeAssistant) -> None: # Check migration to v3.1 assert mock_config_entry.version == 3 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 # Check that model was moved from main data to subentry assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} @@ -584,6 +791,197 @@ async def test_migration_from_v3_1_without_subentry(hass: HomeAssistant) -> None await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.version == 3 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert next(iter(mock_config_entry.subentries.values()), None) is None + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 3, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 2, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_from_v3_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration from version 3.2.""" + # Create a v3.2 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://localhost:11434"}, + disabled_by=config_entry_disabled_by, + version=3, + minor_version=2, + subentries_data=[ + { + "data": V1_TEST_OPTIONS, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": "Ollama", + "unique_id": None, + }, + { + "data": {"model": "llama3.2:latest"}, + "subentry_type": "ai_task_data", + "title": "Ollama AI Task", + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="ollama", + ) + + # Verify initial state + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 2 + assert len(mock_config_entry.subentries) == 2 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 3 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 6309b80b28d..bce1251904a 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -39,7 +39,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/sensed.A', 'friendly_name': '12.111111111111 Sensed A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.12_111111111111_sensed_a', @@ -89,7 +88,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/sensed.B', 'friendly_name': '12.111111111111 Sensed B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.12_111111111111_sensed_b', @@ -139,7 +137,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.0', 'friendly_name': '29.111111111111 Sensed 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_0', @@ -189,7 +186,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.1', 'friendly_name': '29.111111111111 Sensed 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_1', @@ -239,7 +235,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.2', 'friendly_name': '29.111111111111 Sensed 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_2', @@ -289,7 +284,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.3', 'friendly_name': '29.111111111111 Sensed 3', - 'raw_value': None, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_3', @@ -339,7 +333,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.4', 'friendly_name': '29.111111111111 Sensed 4', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_4', @@ -389,7 +382,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.5', 'friendly_name': '29.111111111111 Sensed 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_5', @@ -439,7 +431,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.6', 'friendly_name': '29.111111111111 Sensed 6', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_6', @@ -489,7 +480,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.7', 'friendly_name': '29.111111111111 Sensed 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_7', @@ -539,7 +529,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/sensed.A', 'friendly_name': '3A.111111111111 Sensed A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', @@ -589,7 +578,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/sensed.B', 'friendly_name': '3A.111111111111 Sensed B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', @@ -640,7 +628,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.0', 'friendly_name': 'EF.111111111113 Hub short on branch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', @@ -691,7 +678,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.1', 'friendly_name': 'EF.111111111113 Hub short on branch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', @@ -742,7 +728,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.2', 'friendly_name': 'EF.111111111113 Hub short on branch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', @@ -793,7 +778,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.3', 'friendly_name': 'EF.111111111113 Hub short on branch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', diff --git a/tests/components/onewire/snapshots/test_select.ambr b/tests/components/onewire/snapshots/test_select.ambr index 9861a7d2f5e..d699f717fea 100644 --- a/tests/components/onewire/snapshots/test_select.ambr +++ b/tests/components/onewire/snapshots/test_select.ambr @@ -52,7 +52,6 @@ '11', '12', ]), - 'raw_value': 12.0, }), 'context': , 'entity_id': 'select.28_111111111111_temperature_resolution', diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 8b49b7f3d5f..f19a168456d 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -45,7 +45,6 @@ 'device_class': 'temperature', 'device_file': '/10.111111111111/temperature', 'friendly_name': '10.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -103,7 +102,6 @@ 'device_class': 'pressure', 'device_file': '/12.111111111111/TAI8570/pressure', 'friendly_name': '12.111111111111 Pressure', - 'raw_value': 1025.123, 'state_class': , 'unit_of_measurement': , }), @@ -161,7 +159,6 @@ 'device_class': 'temperature', 'device_file': '/12.111111111111/TAI8570/temperature', 'friendly_name': '12.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -215,7 +212,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/1D.111111111111/counter.A', 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, 'state_class': , }), 'context': , @@ -268,7 +264,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/1D.111111111111/counter.B', 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, 'state_class': , }), 'context': , @@ -325,7 +320,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.A', 'friendly_name': '20.111111111111 Latest voltage A', - 'raw_value': 1.11, 'state_class': , 'unit_of_measurement': , }), @@ -383,7 +377,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.B', 'friendly_name': '20.111111111111 Latest voltage B', - 'raw_value': 2.22, 'state_class': , 'unit_of_measurement': , }), @@ -441,7 +434,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.C', 'friendly_name': '20.111111111111 Latest voltage C', - 'raw_value': 3.33, 'state_class': , 'unit_of_measurement': , }), @@ -499,7 +491,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.D', 'friendly_name': '20.111111111111 Latest voltage D', - 'raw_value': 4.44, 'state_class': , 'unit_of_measurement': , }), @@ -557,7 +548,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.A', 'friendly_name': '20.111111111111 Voltage A', - 'raw_value': 1.1, 'state_class': , 'unit_of_measurement': , }), @@ -615,7 +605,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.B', 'friendly_name': '20.111111111111 Voltage B', - 'raw_value': 2.2, 'state_class': , 'unit_of_measurement': , }), @@ -673,7 +662,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.C', 'friendly_name': '20.111111111111 Voltage C', - 'raw_value': 3.3, 'state_class': , 'unit_of_measurement': , }), @@ -731,7 +719,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.D', 'friendly_name': '20.111111111111 Voltage D', - 'raw_value': 4.4, 'state_class': , 'unit_of_measurement': , }), @@ -789,7 +776,6 @@ 'device_class': 'temperature', 'device_file': '/22.111111111111/temperature', 'friendly_name': '22.111111111111 Temperature', - 'raw_value': None, 'state_class': , 'unit_of_measurement': , }), @@ -844,7 +830,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH3600/humidity', 'friendly_name': '26.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -899,7 +884,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH4000/humidity', 'friendly_name': '26.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -954,7 +938,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH5030/humidity', 'friendly_name': '26.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -1009,7 +992,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HTM1735/humidity', 'friendly_name': '26.111111111111 HTM1735 humidity', - 'raw_value': None, 'state_class': , 'unit_of_measurement': '%', }), @@ -1064,7 +1046,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/humidity', 'friendly_name': '26.111111111111 Humidity', - 'raw_value': 72.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -1119,7 +1100,6 @@ 'device_class': 'illuminance', 'device_file': '/26.111111111111/S3-R1-A/illuminance', 'friendly_name': '26.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -1177,7 +1157,6 @@ 'device_class': 'pressure', 'device_file': '/26.111111111111/B1-R1-A/pressure', 'friendly_name': '26.111111111111 Pressure', - 'raw_value': 969.265, 'state_class': , 'unit_of_measurement': , }), @@ -1235,7 +1214,6 @@ 'device_class': 'temperature', 'device_file': '/26.111111111111/temperature', 'friendly_name': '26.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -1293,7 +1271,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/VAD', 'friendly_name': '26.111111111111 VAD voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -1351,7 +1328,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/VDD', 'friendly_name': '26.111111111111 VDD voltage', - 'raw_value': 4.74, 'state_class': , 'unit_of_measurement': , }), @@ -1409,7 +1385,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/vis', 'friendly_name': '26.111111111111 VIS voltage difference', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -1467,7 +1442,6 @@ 'device_class': 'temperature', 'device_file': '/28.111111111111/temperature', 'friendly_name': '28.111111111111 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1525,7 +1499,6 @@ 'device_class': 'temperature', 'device_file': '/28.222222222222/temperature9', 'friendly_name': '28.222222222222 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1583,7 +1556,6 @@ 'device_class': 'temperature', 'device_file': '/28.222222222223/temperature', 'friendly_name': '28.222222222223 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1641,7 +1613,6 @@ 'device_class': 'temperature', 'device_file': '/30.111111111111/temperature', 'friendly_name': '30.111111111111 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1699,7 +1670,6 @@ 'device_class': 'temperature', 'device_file': '/30.111111111111/typeK/temperature', 'friendly_name': '30.111111111111 Thermocouple K temperature', - 'raw_value': 173.7563, 'state_class': , 'unit_of_measurement': , }), @@ -1757,7 +1727,6 @@ 'device_class': 'voltage', 'device_file': '/30.111111111111/vis', 'friendly_name': '30.111111111111 VIS voltage gradient', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -1815,7 +1784,6 @@ 'device_class': 'voltage', 'device_file': '/30.111111111111/volt', 'friendly_name': '30.111111111111 Voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -1873,7 +1841,6 @@ 'device_class': 'temperature', 'device_file': '/3B.111111111111/temperature', 'friendly_name': '3B.111111111111 Temperature', - 'raw_value': 28.243, 'state_class': , 'unit_of_measurement': , }), @@ -1931,7 +1898,6 @@ 'device_class': 'temperature', 'device_file': '/42.111111111111/temperature', 'friendly_name': '42.111111111111 Temperature', - 'raw_value': 29.123, 'state_class': , 'unit_of_measurement': , }), @@ -1986,7 +1952,6 @@ 'device_class': 'humidity', 'device_file': '/7E.111111111111/EDS0068/humidity', 'friendly_name': '7E.111111111111 Humidity', - 'raw_value': 41.375, 'state_class': , 'unit_of_measurement': '%', }), @@ -2041,7 +2006,6 @@ 'device_class': 'illuminance', 'device_file': '/7E.111111111111/EDS0068/light', 'friendly_name': '7E.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -2099,7 +2063,6 @@ 'device_class': 'pressure', 'device_file': '/7E.111111111111/EDS0068/pressure', 'friendly_name': '7E.111111111111 Pressure', - 'raw_value': 1012.21, 'state_class': , 'unit_of_measurement': , }), @@ -2157,7 +2120,6 @@ 'device_class': 'temperature', 'device_file': '/7E.111111111111/EDS0068/temperature', 'friendly_name': '7E.111111111111 Temperature', - 'raw_value': 13.9375, 'state_class': , 'unit_of_measurement': , }), @@ -2215,7 +2177,6 @@ 'device_class': 'pressure', 'device_file': '/7E.222222222222/EDS0066/pressure', 'friendly_name': '7E.222222222222 Pressure', - 'raw_value': 1012.21, 'state_class': , 'unit_of_measurement': , }), @@ -2273,7 +2234,6 @@ 'device_class': 'temperature', 'device_file': '/7E.222222222222/EDS0066/temperature', 'friendly_name': '7E.222222222222 Temperature', - 'raw_value': 13.9375, 'state_class': , 'unit_of_measurement': , }), @@ -2328,7 +2288,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH3600/humidity', 'friendly_name': 'A6.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2383,7 +2342,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH4000/humidity', 'friendly_name': 'A6.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2438,7 +2396,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH5030/humidity', 'friendly_name': 'A6.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2493,7 +2450,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HTM1735/humidity', 'friendly_name': 'A6.111111111111 HTM1735 humidity', - 'raw_value': None, 'state_class': , 'unit_of_measurement': '%', }), @@ -2548,7 +2504,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/humidity', 'friendly_name': 'A6.111111111111 Humidity', - 'raw_value': 72.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2603,7 +2558,6 @@ 'device_class': 'illuminance', 'device_file': '/A6.111111111111/S3-R1-A/illuminance', 'friendly_name': 'A6.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -2661,7 +2615,6 @@ 'device_class': 'pressure', 'device_file': '/A6.111111111111/B1-R1-A/pressure', 'friendly_name': 'A6.111111111111 Pressure', - 'raw_value': 969.265, 'state_class': , 'unit_of_measurement': , }), @@ -2719,7 +2672,6 @@ 'device_class': 'temperature', 'device_file': '/A6.111111111111/temperature', 'friendly_name': 'A6.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -2777,7 +2729,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/VAD', 'friendly_name': 'A6.111111111111 VAD voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -2835,7 +2786,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/VDD', 'friendly_name': 'A6.111111111111 VDD voltage', - 'raw_value': 4.74, 'state_class': , 'unit_of_measurement': , }), @@ -2893,7 +2843,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/vis', 'friendly_name': 'A6.111111111111 VIS voltage difference', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -2948,7 +2897,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111111/humidity/humidity_corrected', 'friendly_name': 'EF.111111111111 Humidity', - 'raw_value': 67.745, 'state_class': , 'unit_of_measurement': '%', }), @@ -3003,7 +2951,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111111/humidity/humidity_raw', 'friendly_name': 'EF.111111111111 Raw humidity', - 'raw_value': 65.541, 'state_class': , 'unit_of_measurement': '%', }), @@ -3061,7 +3008,6 @@ 'device_class': 'temperature', 'device_file': '/EF.111111111111/humidity/temperature', 'friendly_name': 'EF.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -3119,7 +3065,6 @@ 'device_class': 'pressure', 'device_file': '/EF.111111111112/moisture/sensor.2', 'friendly_name': 'EF.111111111112 Moisture 2', - 'raw_value': 43.123, 'state_class': , 'unit_of_measurement': , }), @@ -3177,7 +3122,6 @@ 'device_class': 'pressure', 'device_file': '/EF.111111111112/moisture/sensor.3', 'friendly_name': 'EF.111111111112 Moisture 3', - 'raw_value': 44.123, 'state_class': , 'unit_of_measurement': , }), @@ -3232,7 +3176,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111112/moisture/sensor.0', 'friendly_name': 'EF.111111111112 Wetness 0', - 'raw_value': 41.745, 'state_class': , 'unit_of_measurement': '%', }), @@ -3287,7 +3230,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111112/moisture/sensor.1', 'friendly_name': 'EF.111111111112 Wetness 1', - 'raw_value': 42.541, 'state_class': , 'unit_of_measurement': '%', }), diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index d819fdd0d54..025fbe1b64b 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -39,7 +39,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/05.111111111111/PIO', 'friendly_name': '05.111111111111 Programmed input-output', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.05_111111111111_programmed_input_output', @@ -89,7 +88,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/latch.A', 'friendly_name': '12.111111111111 Latch A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.12_111111111111_latch_a', @@ -139,7 +137,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/latch.B', 'friendly_name': '12.111111111111 Latch B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.12_111111111111_latch_b', @@ -189,7 +186,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/PIO.A', 'friendly_name': '12.111111111111 Programmed input-output A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.12_111111111111_programmed_input_output_a', @@ -239,7 +235,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/PIO.B', 'friendly_name': '12.111111111111 Programmed input-output B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.12_111111111111_programmed_input_output_b', @@ -289,7 +284,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/26.111111111111/IAD', 'friendly_name': '26.111111111111 Current A/D control', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.26_111111111111_current_a_d_control', @@ -339,7 +333,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.0', 'friendly_name': '29.111111111111 Latch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_0', @@ -389,7 +382,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.1', 'friendly_name': '29.111111111111 Latch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_1', @@ -439,7 +431,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.2', 'friendly_name': '29.111111111111 Latch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_2', @@ -489,7 +480,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.3', 'friendly_name': '29.111111111111 Latch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_3', @@ -539,7 +529,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.4', 'friendly_name': '29.111111111111 Latch 4', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_4', @@ -589,7 +578,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.5', 'friendly_name': '29.111111111111 Latch 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_5', @@ -639,7 +627,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.6', 'friendly_name': '29.111111111111 Latch 6', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_6', @@ -689,7 +676,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.7', 'friendly_name': '29.111111111111 Latch 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_7', @@ -739,7 +725,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.0', 'friendly_name': '29.111111111111 Programmed input-output 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_0', @@ -789,7 +774,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.1', 'friendly_name': '29.111111111111 Programmed input-output 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_1', @@ -839,7 +823,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.2', 'friendly_name': '29.111111111111 Programmed input-output 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_2', @@ -889,7 +872,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.3', 'friendly_name': '29.111111111111 Programmed input-output 3', - 'raw_value': None, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_3', @@ -939,7 +921,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.4', 'friendly_name': '29.111111111111 Programmed input-output 4', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_4', @@ -989,7 +970,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.5', 'friendly_name': '29.111111111111 Programmed input-output 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_5', @@ -1039,7 +1019,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.6', 'friendly_name': '29.111111111111 Programmed input-output 6', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_6', @@ -1089,7 +1068,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.7', 'friendly_name': '29.111111111111 Programmed input-output 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_7', @@ -1139,7 +1117,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/PIO.A', 'friendly_name': '3A.111111111111 Programmed input-output A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', @@ -1189,7 +1166,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/PIO.B', 'friendly_name': '3A.111111111111 Programmed input-output B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', @@ -1239,7 +1215,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/A6.111111111111/IAD', 'friendly_name': 'A6.111111111111 Current A/D control', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.a6_111111111111_current_a_d_control', @@ -1289,7 +1264,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.0', 'friendly_name': 'EF.111111111112 Leaf sensor 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', @@ -1339,7 +1313,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.1', 'friendly_name': 'EF.111111111112 Leaf sensor 1', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', @@ -1389,7 +1362,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.2', 'friendly_name': 'EF.111111111112 Leaf sensor 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', @@ -1439,7 +1411,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.3', 'friendly_name': 'EF.111111111112 Leaf sensor 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', @@ -1489,7 +1460,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.0', 'friendly_name': 'EF.111111111112 Moisture sensor 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', @@ -1539,7 +1509,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.1', 'friendly_name': 'EF.111111111112 Moisture sensor 1', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', @@ -1589,7 +1558,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.2', 'friendly_name': 'EF.111111111112 Moisture sensor 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', @@ -1639,7 +1607,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.3', 'friendly_name': 'EF.111111111112 Moisture sensor 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', @@ -1689,7 +1656,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.0', 'friendly_name': 'EF.111111111113 Hub branch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_0', @@ -1739,7 +1705,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.1', 'friendly_name': 'EF.111111111113 Hub branch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_1', @@ -1789,7 +1754,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.2', 'friendly_name': 'EF.111111111113 Hub branch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_2', @@ -1839,7 +1803,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.3', 'friendly_name': 'EF.111111111113 Hub branch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_3', diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index e728d0019b6..fb8be3b2e68 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,5 +1,6 @@ """Tests for the OpenAI integration.""" +from typing import Any from unittest.mock import AsyncMock, Mock, mock_open, patch import httpx @@ -19,12 +20,18 @@ from syrupy.filters import props from homeassistant.components.openai_conversation import CONF_CHAT_MODEL from homeassistant.components.openai_conversation.const import ( DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, + RECOMMENDED_CONVERSATION_OPTIONS, ) -from homeassistant.config_entries import ConfigSubentryData +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -585,7 +592,7 @@ async def test_migration_from_v1( await hass.async_block_till_done() assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 3 + assert mock_config_entry.minor_version == 4 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -714,7 +721,7 @@ async def test_migration_from_v1_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert len(entry.subentries) == 2 @@ -819,7 +826,7 @@ async def test_migration_from_v1_with_same_keys( entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert ( len(entry.subentries) == 3 @@ -855,6 +862,215 @@ async def test_migration_from_v1_with_same_keys( } +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="ChatGPT", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="ChatGPT 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="chatgpt", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert entry.title == "OpenAI Conversation" + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "ChatGPT" in subentry.title + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -953,7 +1169,7 @@ async def test_migration_from_v2_1( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == "ChatGPT" assert len(entry.subentries) == 3 # 2 conversation + 1 AI task @@ -1089,7 +1305,7 @@ async def test_migration_from_v2_2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == "ChatGPT" assert len(entry.subentries) == 2 @@ -1114,3 +1330,188 @@ async def test_migration_from_v2_2( ai_task_subentry = ai_task_subentries[0] assert ai_task_subentry.data == {"recommended": True} assert ai_task_subentry.title == "OpenAI AI Task" + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 4, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 3, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_from_v2_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration from version 2.3.""" + # Create a v2.3 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + disabled_by=config_entry_disabled_by, + version=2, + minor_version=3, + subentries_data=[ + { + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="chatgpt", + ) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 3 + assert len(mock_config_entry.subentries) == 1 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index c9edfc6808f..4e5c3457fa6 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from opower import CannotConnect, InvalidAuth +from opower import CannotConnect, InvalidAuth, MfaChallenge import pytest from homeassistant import config_entries @@ -43,24 +43,32 @@ async def test_form( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" + # Select utility + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "credentials" + + # Enter credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)" - assert result2["data"] == { + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)" + assert result3["data"] == { "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", @@ -69,33 +77,33 @@ async def test_form( assert mock_login.call_count == 1 -async def test_form_with_mfa( +async def test_form_with_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test we can configure a utility that accepts a TOTP secret.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" + # Select utility result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "utility": "Consolidated Edison (ConEd)", - "username": "test-username", - "password": "test-password", - }, + {"utility": "Consolidated Edison (ConEd)"}, ) assert result2["type"] is FlowResultType.FORM - assert not result2["errors"] + assert result2["step_id"] == "credentials" + # Enter credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { + "username": "test-username", + "password": "test-password", "totp_secret": "test-totp", }, ) @@ -112,43 +120,42 @@ async def test_form_with_mfa( assert mock_login.call_count == 1 -async def test_form_with_mfa_bad_secret( +async def test_form_with_invalid_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test MFA asks for password again when validation fails.""" + """Test we handle an invalid TOTP secret.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "utility": "Consolidated Edison (ConEd)", - "username": "test-username", - "password": "test-password", - }, + {"utility": "Consolidated Edison (ConEd)"}, ) assert result2["type"] is FlowResultType.FORM - assert not result2["errors"] + assert result2["step_id"] == "credentials" + # Enter invalid credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", side_effect=InvalidAuth, - ) as mock_login: + ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "totp_secret": "test-totp", + "username": "test-username", + "password": "test-password", + "totp_secret": "bad-totp", }, ) assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "base": "invalid_auth", - } + assert result3["errors"] == {"base": "invalid_auth"} + assert result3["step_id"] == "credentials" + # Enter valid credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -157,7 +164,7 @@ async def test_form_with_mfa_bad_secret( { "username": "test-username", "password": "updated-password", - "totp_secret": "updated-totp", + "totp_secret": "good-totp", }, ) @@ -167,26 +174,195 @@ async def test_form_with_mfa_bad_secret( "utility": "Consolidated Edison (ConEd)", "username": "test-username", "password": "updated-password", - "totp_secret": "updated-totp", + "totp_secret": "good-totp", } assert len(mock_setup_entry.mock_calls) == 1 assert mock_login.call_count == 1 +async def test_form_with_mfa_challenge( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the full interactive MFA flow, including error recovery.""" + # 1. Start the flow and get to the credentials step + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + + # 2. Trigger an MfaChallenge on login + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = { + "Email": "fooxxx@mail.com", + "Phone": "xxx-123", + } + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login: + result_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + mock_login.assert_awaited_once() + + # 3. Handle the MFA options step, starting with a connection error + assert result_challenge["type"] is FlowResultType.FORM + assert result_challenge["step_id"] == "mfa_options" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + + # Test CannotConnect on selecting MFA method + mock_mfa_handler.async_select_mfa_option.side_effect = CannotConnect + result_mfa_connect_fail = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Email"} + ) + mock_mfa_handler.async_select_mfa_option.assert_awaited_once_with("Email") + assert result_mfa_connect_fail["type"] is FlowResultType.FORM + assert result_mfa_connect_fail["step_id"] == "mfa_options" + assert result_mfa_connect_fail["errors"] == {"base": "cannot_connect"} + + # Retry selecting MFA method successfully + mock_mfa_handler.async_select_mfa_option.side_effect = None + result_mfa_select_ok = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Email"} + ) + assert mock_mfa_handler.async_select_mfa_option.call_count == 2 + assert result_mfa_select_ok["type"] is FlowResultType.FORM + assert result_mfa_select_ok["step_id"] == "mfa_code" + + # 4. Handle the MFA code step, testing multiple failure scenarios + # Test InvalidAuth on submitting code + mock_mfa_handler.async_submit_mfa_code.side_effect = InvalidAuth + result_mfa_invalid_code = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "bad-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_awaited_once_with("bad-code") + assert result_mfa_invalid_code["type"] is FlowResultType.FORM + assert result_mfa_invalid_code["step_id"] == "mfa_code" + assert result_mfa_invalid_code["errors"] == {"base": "invalid_mfa_code"} + + # Test CannotConnect on submitting code + mock_mfa_handler.async_submit_mfa_code.side_effect = CannotConnect + result_mfa_code_connect_fail = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + assert mock_mfa_handler.async_submit_mfa_code.call_count == 2 + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + assert result_mfa_code_connect_fail["type"] is FlowResultType.FORM + assert result_mfa_code_connect_fail["step_id"] == "mfa_code" + assert result_mfa_code_connect_fail["errors"] == {"base": "cannot_connect"} + + # Retry submitting code successfully + mock_mfa_handler.async_submit_mfa_code.side_effect = None + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + assert mock_mfa_handler.async_submit_mfa_code.call_count == 3 + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + + # 5. Verify the flow completes and creates the entry + assert result_final["type"] is FlowResultType.CREATE_ENTRY + assert ( + result_final["title"] + == "Pacific Gas and Electric Company (PG&E) (test-username)" + ) + assert result_final["data"] == { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + "login_data": {"login_data_mock_key": "login_data_mock_value"}, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_with_mfa_challenge_but_no_mfa_options( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the full interactive MFA flow when there are no MFA options.""" + # 1. Start the flow and get to the credentials step + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + + # 2. Trigger an MfaChallenge on login + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = {} + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login: + result_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + mock_login.assert_awaited_once() + + # 3. No MFA options. Handle the MFA code step + assert result_challenge["type"] is FlowResultType.FORM + assert result_challenge["step_id"] == "mfa_code" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + + # 4. Verify the flow completes and creates the entry + assert result_final["type"] is FlowResultType.CREATE_ENTRY + assert ( + result_final["title"] + == "Pacific Gas and Electric Company (PG&E) (test-username)" + ) + assert result_final["data"] == { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + "login_data": {"login_data_mock_key": "login_data_mock_value"}, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + @pytest.mark.parametrize( ("api_exception", "expected_error"), [ - (InvalidAuth(), "invalid_auth"), - (CannotConnect(), "cannot_connect"), + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), ], ) async def test_form_exceptions( - recorder_mock: Recorder, hass: HomeAssistant, api_exception, expected_error + recorder_mock: Recorder, + hass: HomeAssistant, + api_exception: Exception, + expected_error: str, ) -> None: """Test we handle exceptions.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -195,7 +371,6 @@ async def test_form_exceptions( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, @@ -203,15 +378,10 @@ async def test_form_exceptions( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": expected_error} - # On error, the form should have the previous user input, except password, - # as suggested values. + # On error, the form should have the previous user input as suggested values. data_schema = result2["data_schema"].schema - assert ( - get_schema_suggested_value(data_schema, "utility") - == "Pacific Gas and Electric Company (PG&E)" - ) assert get_schema_suggested_value(data_schema, "username") == "test-username" - assert get_schema_suggested_value(data_schema, "password") is None + assert get_schema_suggested_value(data_schema, "password") == "test-password" assert mock_login.call_count == 1 @@ -224,6 +394,10 @@ async def test_form_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -231,7 +405,6 @@ async def test_form_already_configured( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, @@ -252,6 +425,10 @@ async def test_form_not_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -259,7 +436,6 @@ async def test_form_not_already_configured( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username2", "password": "test-password", }, @@ -299,6 +475,16 @@ async def test_form_valid_reauth( assert result["context"]["source"] == "reauth" assert result["context"]["title_placeholders"] == {"name": mock_config_entry.title} + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["data_schema"].schema.keys() == { + "username", + "password", + } + with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -321,22 +507,23 @@ async def test_form_valid_reauth( assert mock_login.call_count == 1 -async def test_form_valid_reauth_with_mfa( +async def test_form_valid_reauth_with_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_unload_entry: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: - """Test that we can handle a valid reauth.""" - hass.config_entries.async_update_entry( - mock_config_entry, + """Test that we can handle a valid reauth for a utility with TOTP.""" + mock_config_entry = MockConfigEntry( + title="Consolidated Edison (ConEd) (test-username)", + domain=DOMAIN, data={ - **mock_config_entry.data, - # Requires MFA "utility": "Consolidated Edison (ConEd)", + "username": "test-username", + "password": "test-password", }, ) + mock_config_entry.add_to_hass(hass) mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) hass.config.components.add(DOMAIN) mock_config_entry.async_start_reauth(hass) @@ -346,6 +533,17 @@ async def test_form_valid_reauth_with_mfa( assert len(flows) == 1 result = flows[0] + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["data_schema"].schema.keys() == { + "username", + "password", + "totp_secret", + } + with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -371,3 +569,109 @@ async def test_form_valid_reauth_with_mfa( assert len(mock_unload_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert mock_login.call_count == 1 + + +async def test_reauth_with_mfa_challenge( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full interactive MFA flow during reauth.""" + # 1. Set up the existing entry and trigger reauth + mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) + hass.config.components.add(DOMAIN) + mock_config_entry.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" + + # 2. Test failure before MFA challenge (InvalidAuth) + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ) as mock_login_fail_auth: + result_invalid_auth = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "bad-password", + }, + ) + mock_login_fail_auth.assert_awaited_once() + assert result_invalid_auth["type"] is FlowResultType.FORM + assert result_invalid_auth["step_id"] == "reauth_confirm" + assert result_invalid_auth["errors"] == {"base": "invalid_auth"} + + # 3. Test failure before MFA challenge (CannotConnect) + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=CannotConnect, + ) as mock_login_fail_connect: + result_cannot_connect = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-password", + }, + ) + mock_login_fail_connect.assert_awaited_once() + assert result_cannot_connect["type"] is FlowResultType.FORM + assert result_cannot_connect["step_id"] == "reauth_confirm" + assert result_cannot_connect["errors"] == {"base": "cannot_connect"} + + # 4. Trigger the MfaChallenge on the next attempt + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = { + "Email": "fooxxx@mail.com", + "Phone": "xxx-123", + } + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login_mfa: + result_mfa_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-password", + }, + ) + mock_login_mfa.assert_awaited_once() + + # 5. Handle the happy path for the MFA flow + assert result_mfa_challenge["type"] is FlowResultType.FORM + assert result_mfa_challenge["step_id"] == "mfa_options" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + + result_mfa_code = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Phone"} + ) + mock_mfa_handler.async_select_mfa_option.assert_awaited_once_with("Phone") + assert result_mfa_code["type"] is FlowResultType.FORM + assert result_mfa_code["step_id"] == "mfa_code" + + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_awaited_once_with("good-code") + + # 6. Verify the reauth completes successfully + assert result_final["type"] is FlowResultType.ABORT + assert result_final["reason"] == "reauth_successful" + await hass.async_block_till_done() + + # Check that data was updated and the entry was reloaded + assert mock_config_entry.data["password"] == "new-password" + assert mock_config_entry.data["login_data"] == { + "login_data_mock_key": "login_data_mock_value" + } + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/overseerr/test_services.py b/tests/components/overseerr/test_services.py index 3d7bcc3577f..f53c6a917cb 100644 --- a/tests/components/overseerr/test_services.py +++ b/tests/components/overseerr/test_services.py @@ -7,13 +7,13 @@ from python_overseerr import OverseerrConnectionError from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, ) from homeassistant.components.overseerr.services import SERVICE_GET_REQUESTS +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index bc3de313a86..7120e0f87f0 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator import json from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -130,6 +130,28 @@ def mock_smile_config_flow() -> Generator[MagicMock]: yield api +@pytest.fixture +def platforms() -> list[str]: + """Fixture for platforms.""" + return [] + + +@pytest.fixture +async def setup_platform( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms, +) -> AsyncGenerator[None]: + """Set up one or all platforms.""" + + mock_config_entry.add_to_hass(hass) + + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield mock_config_entry + + @pytest.fixture def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam environment for testing exceptions.""" diff --git a/tests/components/plugwise/snapshots/test_button.ambr b/tests/components/plugwise/snapshots/test_button.ambr new file mode 100644 index 00000000000..900d85db527 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_button.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_adam_button_snapshot[platforms0][button.adam_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.adam_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reboot', + 'unique_id': 'fe799307f1624099878210aa0b9f1475-reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_button_snapshot[platforms0][button.adam_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Adam Reboot', + }), + 'context': , + 'entity_id': 'button.adam_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/plugwise/test_button.py b/tests/components/plugwise/test_button.py index 23003b3ffe6..8667e2ef893 100644 --- a/tests/components/plugwise/test_button.py +++ b/tests/components/plugwise/test_button.py @@ -2,32 +2,34 @@ from unittest.mock import MagicMock -from homeassistant.components.button import ( - DOMAIN as BUTTON_DOMAIN, - SERVICE_PRESS, - ButtonDeviceClass, -) -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -async def test_adam_reboot_button( +@pytest.mark.parametrize("platforms", [(BUTTON_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_button_snapshot( + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam button snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +async def test_adam_press_reboot_button( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test creation of button entities.""" - state = hass.states.get("button.adam_reboot") - assert state - assert state.state == STATE_UNKNOWN - assert state.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART - - registry = er.async_get(hass) - entry = registry.async_get("button.adam_reboot") - assert entry - assert entry.unique_id == "fe799307f1624099878210aa0b9f1475-reboot" - + """Test pressing of button entity.""" await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index fa4cac6fff3..48b024e0b10 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -128,7 +128,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 - host_mock.wifi_connection = False + host_mock.wifi_connection.return_value = False host_mock.wifi_signal.return_value = -45 host_mock.whiteled_mode_list.return_value = [] host_mock.post_recording_time_list.return_value = [] diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index c2b059d658b..ca35d7eb70f 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -38,7 +38,7 @@ 'ONVIF enabled': True, 'RTMP enabled': True, 'RTSP enabled': True, - 'WiFi connection': False, + 'WiFi connection': True, 'WiFi signal': -45, 'abilities': dict({ 'abilityChn': list([ @@ -90,8 +90,8 @@ 'null': 5, }), 'GetAiCfg': dict({ - '0': 4, - 'null': 4, + '0': 2, + 'null': 2, }), 'GetAudioAlarm': dict({ '0': 1, @@ -177,10 +177,6 @@ '0': 2, 'null': 2, }), - 'GetPtzTraceSection': dict({ - '0': 2, - 'null': 2, - }), 'GetPush': dict({ '0': 1, 'null': 2, @@ -196,8 +192,8 @@ 'null': 1, }), 'GetWhiteLed': dict({ - '0': 3, - 'null': 3, + '0': 2, + 'null': 2, }), 'GetZoomFocus': dict({ '0': 2, diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py index b347bae9ec0..3e8ab4d0b2b 100644 --- a/tests/components/reolink/test_diagnostics.py +++ b/tests/components/reolink/test_diagnostics.py @@ -21,6 +21,8 @@ async def test_entry_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test Reolink diagnostics.""" + reolink_host.wifi_connection.return_value = True + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index b30f0c2a61a..9b32f70a9bd 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -21,7 +21,7 @@ async def test_sensors( ) -> None: """Test sensor entities.""" reolink_host.ptz_pan_position.return_value = 1200 - reolink_host.wifi_connection = True + reolink_host.wifi_connection.return_value = True reolink_host.wifi_signal.return_value = -55 reolink_host.hdd_list = [0] reolink_host.hdd_storage.return_value = 95 diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 7958f17a696..994f58513d2 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -239,8 +239,11 @@ async def test_reauth_flow( assert result["step_id"] == "reauth_confirm" # Request a new code - with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + with ( + patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ), + patch("homeassistant.components.roborock.async_setup_entry", return_value=True), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -250,9 +253,12 @@ async def test_reauth_flow( assert result["type"] is FlowResultType.FORM new_user_data = deepcopy(USER_DATA) new_user_data.rriot.s = "new_password_hash" - with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", - return_value=new_user_data, + with ( + patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=new_user_data, + ), + patch("homeassistant.components.roborock.async_setup_entry", return_value=True), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} diff --git a/tests/components/stookwijzer/test_services.py b/tests/components/stookwijzer/test_services.py index f60730a290d..d7ec036d6e4 100644 --- a/tests/components/stookwijzer/test_services.py +++ b/tests/components/stookwijzer/test_services.py @@ -3,11 +3,8 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.stookwijzer.const import ( - ATTR_CONFIG_ENTRY_ID, - DOMAIN, - SERVICE_GET_FORECAST, -) +from homeassistant.components.stookwijzer.const import DOMAIN, SERVICE_GET_FORECAST +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er diff --git a/tests/components/swiss_public_transport/test_service.py b/tests/components/swiss_public_transport/test_service.py index 135fb07fda8..b65ffc12de1 100644 --- a/tests/components/swiss_public_transport/test_service.py +++ b/tests/components/swiss_public_transport/test_service.py @@ -12,7 +12,6 @@ import pytest from voluptuous import error as vol_er from homeassistant.components.swiss_public_transport.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_LIMIT, CONF_DESTINATION, CONF_START, @@ -22,6 +21,7 @@ from homeassistant.components.swiss_public_transport.const import ( SERVICE_FETCH_CONNECTIONS, ) from homeassistant.components.swiss_public_transport.helper import unique_id_from_config +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 6c7222645b6..8c2773956b2 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -15,7 +15,7 @@ from homeassistant.components.vacuum import ( 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 import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component @@ -589,6 +589,40 @@ async def test_battery_level_template( _verify(hass, STATE_UNKNOWN, expected) +@pytest.mark.parametrize( + ("count", "state_template", "extra_config", "attribute_template"), + [(1, "{{ states('sensor.test_state') }}", {}, "{{ 50 }}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "battery_level_template"), + (ConfigurationStyle.MODERN, "battery_level"), + (ConfigurationStyle.TRIGGER, "battery_level"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_battery_level_template_repair( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test battery_level template raises issue.""" + # Ensure trigger entity templates are rendered + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + "template", f"deprecated_battery_level_{TEST_ENTITY_ID}" + ) + assert issue.domain == "template" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders["entity_name"] == TEST_OBJECT_ID + assert issue.translation_placeholders["entity_id"] == TEST_ENTITY_ID + assert "Detected that integration 'template' is setting the" not in caplog.text + + @pytest.mark.parametrize( ("count", "state_template", "extra_config"), [ diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 7d6cd32959c..1def19a06bd 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -14,6 +14,25 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DEVICE_MOCKS = { + "cl_3r8gc33pnqsxfe1g": [ + # https://github.com/tuya/tuya-home-assistant/issues/754 + Platform.COVER, + Platform.SENSOR, + Platform.SWITCH, + ], + "cl_cpbo62rn": [ + # https://github.com/orgs/home-assistant/discussions/539 + Platform.COVER, + Platform.SELECT, + ], + "cl_ebt12ypvexnixvtf": [ + # https://github.com/tuya/tuya-home-assistant/issues/754 + Platform.COVER, + ], + "cl_qqdxfdht": [ + # https://github.com/orgs/home-assistant/discussions/539 + Platform.COVER, + ], "cl_zah67ekd": [ # https://github.com/home-assistant/core/issues/71242 Platform.COVER, @@ -41,6 +60,11 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "cs_qhxmvae667uap4zh": [ + # https://github.com/home-assistant/core/issues/141278 + Platform.FAN, + Platform.HUMIDIFIER, + ], "cs_vmxuxszzjwp5smli": [ # https://github.com/home-assistant/core/issues/119865 Platform.FAN, @@ -214,6 +238,16 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/143499 Platform.SENSOR, ], + "fs_g0ewlb1vmwqljzji": [ + # https://github.com/home-assistant/core/issues/141231 + Platform.FAN, + Platform.LIGHT, + Platform.SELECT, + ], + "fs_ibytpo6fpnugft1c": [ + # https://github.com/home-assistant/core/issues/135541 + Platform.FAN, + ], "gyd_lgekqfxdabipm3tn": [ # https://github.com/home-assistant/core/issues/133173 Platform.LIGHT, @@ -227,6 +261,11 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/148347 Platform.SWITCH, ], + "kj_CAjWAxBUZt7QZHfz": [ + # https://github.com/home-assistant/core/issues/146023 + Platform.FAN, + Platform.SWITCH, + ], "kj_yrzylxax1qspdgpp": [ # https://github.com/orgs/home-assistant/discussions/61 Platform.FAN, @@ -254,6 +293,12 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "mzj_qavcakohisj5adyh": [ + # https://github.com/home-assistant/core/issues/141278 + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + ], "pc_t2afic7i3v1bwhfp": [ # https://github.com/home-assistant/core/issues/149704 Platform.SWITCH, @@ -294,6 +339,15 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "sd_lr33znaodtyarrrz": [ + # https://github.com/home-assistant/core/issues/141278 + Platform.BUTTON, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.VACUUM, + ], "sfkzq_o6dagifntoafakst": [ # https://github.com/home-assistant/core/issues/148116 Platform.SWITCH, @@ -306,21 +360,56 @@ DEVICE_MOCKS = { ], "sp_drezasavompxpcgm": [ # https://github.com/home-assistant/core/issues/149704 + Platform.CAMERA, Platform.LIGHT, Platform.SELECT, Platform.SWITCH, ], "sp_rjKXWRohlvOTyLBu": [ # https://github.com/home-assistant/core/issues/149704 + Platform.CAMERA, Platform.LIGHT, Platform.SELECT, Platform.SWITCH, ], + "sp_sdd5f5f2dl5wydjf": [ + # https://github.com/home-assistant/core/issues/144087 + Platform.CAMERA, + Platform.NUMBER, + Platform.SENSOR, + Platform.SELECT, + Platform.SIREN, + Platform.SWITCH, + ], + "tdq_1aegphq4yfd50e6b": [ + # https://github.com/home-assistant/core/issues/143209 + Platform.SELECT, + Platform.SWITCH, + ], + "tdq_9htyiowaf5rtdhrv": [ + # https://github.com/home-assistant/core/issues/143209 + Platform.SELECT, + Platform.SWITCH, + ], "tdq_cq1p0nt0a4rixnex": [ # https://github.com/home-assistant/core/issues/146845 Platform.SELECT, Platform.SWITCH, ], + "tdq_nockvv2k39vbrxxk": [ + # https://github.com/home-assistant/core/issues/145849 + Platform.SWITCH, + ], + "tdq_pu8uhxhwcp3tgoz7": [ + # https://github.com/home-assistant/core/issues/141278 + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ], + "tdq_uoa3mayicscacseb": [ + # https://github.com/home-assistant/core/issues/128911 + # SDK information is empty + ], "tyndj_pyakuuoc": [ # https://github.com/home-assistant/core/issues/149704 Platform.LIGHT, @@ -335,6 +424,10 @@ DEVICE_MOCKS = { Platform.CLIMATE, Platform.SWITCH, ], + "wg2_nwxr8qcu4seltoro": [ + # https://github.com/orgs/home-assistant/discussions/430 + Platform.BINARY_SENSOR, + ], "wk_fi6dne5tu4t1nm6j": [ # https://github.com/orgs/home-assistant/discussions/243 Platform.CLIMATE, @@ -360,14 +453,24 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "ywcgq_h8lvyoahr6s6aybf": [ + # https://github.com/home-assistant/core/issues/145932 + Platform.NUMBER, + Platform.SENSOR, + ], "ywcgq_wtzwyhkev3b4ubns": [ - # https://community.home-assistant.io/t/something-is-wrong-with-tuya-tank-level-sensors-with-the-new-official-integration/689321 - # not (yet) supported + # https://github.com/home-assistant/core/issues/103818 + Platform.NUMBER, + Platform.SENSOR, ], "zndb_ze8faryrxr0glqnn": [ # https://github.com/home-assistant/core/issues/138372 Platform.SENSOR, ], + "zwjcy_myd45weu": [ + # https://github.com/orgs/home-assistant/discussions/482 + Platform.SENSOR, + ], } diff --git a/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json b/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json new file mode 100644 index 00000000000..de6c23a1c14 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json @@ -0,0 +1,123 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Lounge Dark Blind", + "model": null, + "category": "cl", + "product_id": "3r8gc33pnqsxfe1g", + "product_name": "Blinds Controller", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2022-01-01T20:55:54+00:00", + "create_time": "2021-07-26T15:33:42+00:00", + "update_time": "2022-02-12T10:40:15+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "control_back": { + "type": "Boolean", + "value": {} + }, + "countdown": { + "type": "Enum", + "value": { + "range": ["cancel", "1", "2", "3", "4"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "percent_state": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "control_back": { + "type": "Boolean", + "value": {} + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opening", "closing"] + } + }, + "countdown": { + "type": "Enum", + "value": { + "range": ["cancel", "1", "2", "3", "4"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "time_total": { + "type": "Integer", + "value": { + "unit": "ms", + "min": 0, + "max": 120000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "control": "open", + "percent_control": 0, + "percent_state": 0, + "control_back": true, + "work_state": "opening", + "countdown": "cancel", + "countdown_left": 0, + "time_total": 25400 + }, + "terminal_id": "REDACTED" +} diff --git a/tests/components/tuya/fixtures/cl_cpbo62rn.json b/tests/components/tuya/fixtures/cl_cpbo62rn.json new file mode 100644 index 00000000000..a5ed8e4b580 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_cpbo62rn.json @@ -0,0 +1,102 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf216113c71bf01a18jtl0", + "name": "blinds", + "category": "cl", + "product_id": "cpbo62rn", + "product_name": "curtain robot", + "online": true, + "sub": true, + "time_zone": "+00:00", + "active_time": "2023-06-29T15:14:19+00:00", + "create_time": "2023-06-29T15:14:19+00:00", + "update_time": "2023-06-29T15:14:19+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["morning", "night"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "percent_state": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["morning", "night"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["motor_fault"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "control": "stop", + "percent_control": 63, + "percent_state": 64, + "mode": "morning", + "fault": 0, + "battery_percentage": 100 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json b/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json new file mode 100644 index 00000000000..4b15a27bfd5 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json @@ -0,0 +1,58 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Kitchen Blinds", + "model": "KASMARTBLIA", + "category": "cl", + "product_id": "ebt12ypvexnixvtf", + "product_name": "Smart Blinds", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2022-01-13T23:10:34+00:00", + "create_time": "2022-01-13T23:10:34+00:00", + "update_time": "2022-02-12T10:40:15+00:00", + "function": { + "switch_1": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "percent_control": 0 + }, + "terminal_id": "REDACTED" +} diff --git a/tests/components/tuya/fixtures/cl_qqdxfdht.json b/tests/components/tuya/fixtures/cl_qqdxfdht.json new file mode 100644 index 00000000000..b8f568619db --- /dev/null +++ b/tests/components/tuya/fixtures/cl_qqdxfdht.json @@ -0,0 +1,67 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfb9c4958fd06d141djpqa", + "name": "bedroom blinds", + "category": "cl", + "product_id": "qqdxfdht", + "product_name": "Blinds Drive-BLE", + "online": true, + "sub": true, + "time_zone": "+00:00", + "active_time": "2021-11-09T08:38:29+00:00", + "create_time": "2021-11-09T08:38:29+00:00", + "update_time": "2021-11-09T08:38:29+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": "0", + "max": "100", + "scale": "0", + "step": "1" + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": "0", + "max": "100", + "scale": "0", + "step": "1" + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opening", "closing"] + } + } + }, + "status": { + "control": "stop", + "percent_control": 100, + "work_state": "closing" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/clkg_nhyj64w2.json b/tests/components/tuya/fixtures/clkg_nhyj64w2.json index 28e3248f8b5..0f64bae778f 100644 --- a/tests/components/tuya/fixtures/clkg_nhyj64w2.json +++ b/tests/components/tuya/fixtures/clkg_nhyj64w2.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1729466466688hgsTp2", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json index 8d7e744fb52..fb544fb7d5e 100644 --- a/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json +++ b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1732306182276g6jQLp", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json b/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json index 8a2fd881262..755b46fa397 100644 --- a/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json +++ b/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json b/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json new file mode 100644 index 00000000000..9b0b704e3de --- /dev/null +++ b/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json @@ -0,0 +1,32 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "28403630e8db84b7a963", + "name": "DryFix", + "category": "cs", + "product_id": "qhxmvae667uap4zh", + "product_name": "", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-04-03T13:10:02+00:00", + "create_time": "2024-04-03T13:10:02+00:00", + "update_time": "2024-04-03T13:10:02+00:00", + "function": {}, + "status_range": { + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json b/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json index ff922f506c5..27d4e825ab1 100644 --- a/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json +++ b/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cwjwq_agwu93lr.json b/tests/components/tuya/fixtures/cwjwq_agwu93lr.json index a4a9fc6aaff..84f76908338 100644 --- a/tests/components/tuya/fixtures/cwjwq_agwu93lr.json +++ b/tests/components/tuya/fixtures/cwjwq_agwu93lr.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1750837476328i3TNXQ", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json b/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json index ec6f3ce5122..4bdd6f3167d 100644 --- a/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json +++ b/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1747045731408d0tb5M", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json b/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json index 0f5e5e5f241..695da229041 100644 --- a/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json +++ b/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1751729689584Vh0VoL", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json b/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json index 9cd3c4ffd6f..27c3ae0c37f 100644 --- a/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json +++ b/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1742695000703Ozq34h", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json b/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json index 8e9a06cc9a9..2652399bdcb 100644 --- a/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json +++ b/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1733006572651YokbqV", + "terminal_id": "REDACTED", "mqtt_connected": null, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json b/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json new file mode 100644 index 00000000000..3aae03c904a --- /dev/null +++ b/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json @@ -0,0 +1,134 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "XXX", + "name": "Ceiling Fan With Light", + "category": "fs", + "product_id": "g0ewlb1vmwqljzji", + "product_name": "Ceiling Fan With Light", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-22T22:57:04+00:00", + "create_time": "2025-03-22T22:57:04+00:00", + "update_time": "2025-03-22T22:57:04+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["normal", "sleep", "nature"] + } + }, + "fan_speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + }, + "fan_direction": { + "type": "Enum", + "value": { + "range": ["forward", "reverse"] + } + }, + "light": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "4h", "8h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["normal", "sleep", "nature"] + } + }, + "fan_speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + }, + "fan_direction": { + "type": "Enum", + "value": { + "range": ["forward", "reverse"] + } + }, + "light": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "4h", "8h"] + } + } + }, + "status": { + "switch": true, + "mode": "normal", + "fan_speed": 1, + "fan_direction": "reverse", + "light": true, + "bright_value": 100, + "temp_value": 0, + "countdown_set": "off" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json new file mode 100644 index 00000000000..02b3808f84d --- /dev/null +++ b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json @@ -0,0 +1,23 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "10706550a4e57c88b93a", + "name": "Ventilador Cama", + "category": "fs", + "product_id": "ibytpo6fpnugft1c", + "product_name": "Tower bladeless fan ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-10T18:47:46+00:00", + "create_time": "2025-01-10T18:47:46+00:00", + "update_time": "2025-01-10T18:47:46+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json b/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json index 28f2b8e8f46..ddfbce3ae11 100644 --- a/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json +++ b/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json @@ -1,10 +1,9 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1732306182276g6jQLp", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "eb3e988f33c233290cfs3l", "name": "Colorful PIR Night Light", "category": "gyd", diff --git a/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json b/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json index 63d9148afbf..a190161953b 100644 --- a/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json +++ b/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1750526976566fMhqJs", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": true, diff --git a/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json b/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json new file mode 100644 index 00000000000..5758fce2152 --- /dev/null +++ b/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json @@ -0,0 +1,86 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "152027113c6105cce49c", + "name": "HL400", + "category": "kj", + "product_id": "CAjWAxBUZt7QZHfz", + "product_name": "air purifier", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-13T11:02:55+00:00", + "create_time": "2025-05-13T11:02:55+00:00", + "update_time": "2025-05-13T11:02:55+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "uv": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3"] + } + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "pm25": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 500, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "lock": false, + "anion": true, + "speed": 3, + "uv": true, + "pm25": 45 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json b/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json index 909022793ba..642ef968608 100644 --- a/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json +++ b/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "CENSORED", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json b/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json index 071596e8e6c..cb158a967b4 100644 --- a/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json +++ b/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json b/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json index 8fa2d7b0512..5b29fd0a191 100644 --- a/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json +++ b/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json b/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json new file mode 100644 index 00000000000..402e73c732b --- /dev/null +++ b/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json @@ -0,0 +1,119 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bff434eca843ffc9afmthv", + "name": "Sous Vide", + "category": "mzj", + "product_id": "qavcakohisj5adyh", + "product_name": "Sous Vide", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-08T17:56:06+00:00", + "create_time": "2025-01-08T17:56:06+00:00", + "update_time": "2025-01-08T17:56:06+00:00", + "function": { + "start": { + "type": "Boolean", + "value": {} + }, + "cook_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 250, + "max": 925, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "start": { + "type": "Boolean", + "value": {} + }, + "status": { + "type": "Enum", + "value": { + "range": ["standby", "cooking", "done"] + } + }, + "cook_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "remain_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 250, + "max": 925, + "scale": 1, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status": { + "start": false, + "status": "standby", + "cook_time": 1, + "remain_time": 1, + "cook_temperature": 550, + "temp_current": 267, + "temp_unit_convert": "c" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json b/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json index 1ae5e966de7..6cae732aedf 100644 --- a/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json +++ b/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1737479380414pasuj4", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json b/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json index c52086213fd..c538630c542 100644 --- a/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json +++ b/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1751921699759JsVujI", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json b/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json index caccb0b9234..efffe12a2f9 100644 --- a/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json +++ b/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1708196692712PHOeqy", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json b/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json index 58cbaedb0f1..24b4dbda594 100644 --- a/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json +++ b/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "17421891051898r7yM6", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json b/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json new file mode 100644 index 00000000000..77d94cb951b --- /dev/null +++ b/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json @@ -0,0 +1,476 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfa951ca98fcf64fddqlmt", + "name": "V20", + "category": "sd", + "product_id": "lr33znaodtyarrrz", + "product_name": "V20", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-23T16:37:02+00:00", + "create_time": "2025-03-23T16:37:02+00:00", + "update_time": "2025-03-23T16:37:02+00:00", + "function": { + "power_go": { + "type": "Boolean", + "value": {} + }, + "pause": { + "type": "Boolean", + "value": {} + }, + "switch_charge": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["smart", "zone", "pose", "part"] + } + }, + "suction": { + "type": "Enum", + "value": { + "range": ["gentle", "normal", "strong"] + } + }, + "cistern": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "seek": { + "type": "Boolean", + "value": {} + }, + "direction_control": { + "type": "Enum", + "value": { + "range": ["forward", "turn_left", "turn_right", "stop"] + } + }, + "reset_map": { + "type": "Boolean", + "value": {} + }, + "path_data": { + "type": "Raw", + "value": {} + }, + "command_trans": { + "type": "Raw", + "value": {} + }, + "request": { + "type": "Enum", + "value": { + "range": ["get_map", "get_path", "get_both"] + } + }, + "reset_edge_brush": { + "type": "Boolean", + "value": {} + }, + "reset_roll_brush": { + "type": "Boolean", + "value": {} + }, + "reset_filter": { + "type": "Boolean", + "value": {} + }, + "reset_duster_cloth": { + "type": "Boolean", + "value": {} + }, + "switch_disturb": { + "type": "Boolean", + "value": {} + }, + "volume_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "break_clean": { + "type": "Boolean", + "value": {} + }, + "device_timer": { + "type": "Raw", + "value": {} + }, + "disturb_time_set": { + "type": "Raw", + "value": {} + }, + "voice_data": { + "type": "Raw", + "value": {} + }, + "language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "german", + "french", + "russian", + "spanish", + "korean", + "latin", + "portuguese", + "japanese", + "italian" + ] + } + }, + "customize_mode_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "power_go": { + "type": "Boolean", + "value": {} + }, + "pause": { + "type": "Boolean", + "value": {} + }, + "switch_charge": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["smart", "zone", "pose", "part"] + } + }, + "status": { + "type": "Enum", + "value": { + "range": [ + "standby", + "zone_clean", + "part_clean", + "cleaning", + "paused", + "goto_pos", + "pos_arrived", + "pos_unarrive", + "goto_charge", + "charging", + "charge_done", + "sleep" + ] + } + }, + "clean_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9999, + "scale": 0, + "step": 1 + } + }, + "clean_area": { + "type": "Integer", + "value": { + "unit": "㎡", + "min": 0, + "max": 9999, + "scale": 0, + "step": 1 + } + }, + "electricity_left": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "suction": { + "type": "Enum", + "value": { + "range": ["closed", "gentle", "normal", "strong"] + } + }, + "cistern": { + "type": "Enum", + "value": { + "range": ["closed", "low", "middle", "high"] + } + }, + "seek": { + "type": "Boolean", + "value": {} + }, + "direction_control": { + "type": "Enum", + "value": { + "range": ["forward", "turn_left", "turn_right", "stop"] + } + }, + "reset_map": { + "type": "Boolean", + "value": {} + }, + "path_data": { + "type": "Raw", + "value": {} + }, + "command_trans": { + "type": "Raw", + "value": {} + }, + "request": { + "type": "Enum", + "value": { + "range": ["get_map", "get_path", "get_both"] + } + }, + "edge_brush": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_edge_brush": { + "type": "Boolean", + "value": {} + }, + "roll_brush": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 18000, + "scale": 0, + "step": 1 + } + }, + "reset_roll_brush": { + "type": "Boolean", + "value": {} + }, + "filter": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_filter": { + "type": "Boolean", + "value": {} + }, + "duster_cloth": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_duster_cloth": { + "type": "Boolean", + "value": {} + }, + "switch_disturb": { + "type": "Boolean", + "value": {} + }, + "volume_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "break_clean": { + "type": "Boolean", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "low_power", + "poweroff", + "wheel_trap", + "cannot_upgrade", + "collision_stuck", + "dust_station_full", + "tile_error", + "lidar_speed_err", + "lidar_cover", + "lidar_point_err", + "front_wall_dirty", + "psd_dirty", + "middle_sweep", + "side_sweep", + "fan_speed", + "dustbox_out", + "dustbox_full", + "no_dust_box", + "dustbox_fullout", + "trapped", + "pick_up", + "no_dust_water_box", + "water_box_empty", + "forbid_area", + "land_check", + "findcharge_fail", + "battery_err", + "kit_wheel", + "kit_lidar", + "kit_water_pump" + ] + } + }, + "total_clean_area": { + "type": "Integer", + "value": { + "unit": "㎡", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "total_clean_count": { + "type": "Integer", + "value": { + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "total_clean_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "device_timer": { + "type": "Raw", + "value": {} + }, + "disturb_time_set": { + "type": "Raw", + "value": {} + }, + "device_info": { + "type": "Raw", + "value": {} + }, + "voice_data": { + "type": "Raw", + "value": {} + }, + "language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "german", + "french", + "russian", + "spanish", + "korean", + "latin", + "portuguese", + "japanese", + "italian" + ] + } + }, + "customize_mode_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "power_go": false, + "pause": false, + "switch_charge": false, + "mode": "goto_charge", + "status": "charge_done", + "clean_time": 0, + "clean_area": 0, + "electricity_left": 100, + "suction": "strong", + "cistern": "middle", + "seek": false, + "direction_control": "forward", + "reset_map": false, + "path_data": "", + "command_trans": "qgABFxc=", + "request": "get_map", + "edge_brush": 8944, + "reset_edge_brush": false, + "roll_brush": 17948, + "reset_roll_brush": false, + "filter": 8956, + "reset_filter": false, + "duster_cloth": 9000, + "reset_duster_cloth": false, + "switch_disturb": false, + "volume_set": 95, + "break_clean": true, + "fault": 0, + "total_clean_area": 24, + "total_clean_count": 1, + "total_clean_time": 42, + "device_timer": "qgADMQEAMg==", + "disturb_time_set": "qgAIMwEWAAAIAABS", + "device_info": "eyJEZXZpY2VfU04iOiJJRlYyMDI1MDExNTAyMDIwMiIsIkZpcm13YXJlX1ZlcnNpb24iOiIxLjQuMyIsIklQIjoiMTkyLjE2OC4wLjIwMyIsIk1DVV9WZXJzaW9uIjoiMC4zMTQxLjEwNyIsIk1hYyI6IjM0OjE3OjM2OkU1OjAyOjc4IiwiTW9kdWxlX1VVSUQiOiJ6ZjExYjJmNzQ4Mzg5ZTY5ZDk4NiIsIlJTU0kiOiItNTAiLCJXaUZpX05hbWUiOiJGcnl0a2lfemFfZGFybW8ifQ==", + "voice_data": "qwAAAAAHNQAAAAADZJw=", + "language": "chinese_simplified", + "customize_mode_switch": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json b/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json index dd95050e2bf..e57e9274690 100644 --- a/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json +++ b/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1739471569144tcmeiO", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json b/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json new file mode 100644 index 00000000000..7e4705650b1 --- /dev/null +++ b/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json @@ -0,0 +1,383 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf3f8b448bbc123e29oghf", + "name": "C9", + "category": "sp", + "product_id": "sdd5f5f2dl5wydjf", + "product_name": "Security Camera", + "online": true, + "sub": false, + "time_zone": "+11:00", + "active_time": "2025-03-13T07:28:30+00:00", + "create_time": "2025-03-13T07:28:30+00:00", + "update_time": "2025-03-13T07:28:30+00:00", + "function": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_wdr": { + "type": "Boolean", + "value": {} + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4", "5", "6", "7"] + } + }, + "ipc_auto_siren": { + "type": "Boolean", + "value": {} + }, + "nightvision_mode": { + "type": "Enum", + "value": { + "range": ["auto", "ir_mode", "color_mode"] + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "wireless_lowpower": { + "type": "Integer", + "value": { + "min": 10, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "wireless_awake": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "pir_switch": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4"] + } + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "basic_device_volume": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "device_restart": { + "type": "Boolean", + "value": {} + }, + "humanoid_filter": { + "type": "Boolean", + "value": {} + }, + "cruise_switch": { + "type": "Boolean", + "value": {} + }, + "cruise_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "ipc_work_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + } + }, + "status_range": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_wdr": { + "type": "Boolean", + "value": {} + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "min": 1, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "min": -20000, + "max": 200000, + "scale": 0, + "step": 1 + } + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4", "5", "6", "7"] + } + }, + "ipc_auto_siren": { + "type": "Boolean", + "value": {} + }, + "nightvision_mode": { + "type": "Enum", + "value": { + "range": ["auto", "ir_mode", "color_mode"] + } + }, + "battery_report_cap": { + "type": "Integer", + "value": { + "min": 0, + "max": 15, + "scale": 0, + "step": 1 + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "doorbell_active": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "wireless_electricity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "wireless_powermode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "wireless_lowpower": { + "type": "Integer", + "value": { + "min": 10, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "wireless_awake": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "pir_switch": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4"] + } + }, + "doorbell_pic": { + "type": "Raw", + "value": {} + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "basic_device_volume": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "device_restart": { + "type": "Boolean", + "value": {} + }, + "humanoid_filter": { + "type": "Boolean", + "value": {} + }, + "cruise_switch": { + "type": "Boolean", + "value": {} + }, + "cruise_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "alarm_message": { + "type": "String", + "value": {} + }, + "ipc_work_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "initiative_message": { + "type": "Raw", + "value": {} + } + }, + "status": { + "basic_flip": false, + "basic_osd": true, + "motion_sensitivity": 1, + "basic_wdr": false, + "sd_storge": "30932992|3407872|27525120", + "sd_status": 1, + "sd_format": false, + "motion_record": false, + "movement_detect_pic": "**REDACTED**", + "ptz_stop": true, + "sd_format_state": 0, + "ptz_control": 5, + "ipc_auto_siren": false, + "nightvision_mode": "auto", + "battery_report_cap": 1, + "ptz_calibration": false, + "motion_switch": true, + "doorbell_active": "", + "wireless_electricity": 80, + "wireless_powermode": 0, + "wireless_lowpower": 10, + "wireless_awake": false, + "record_switch": true, + "record_mode": 1, + "pir_switch": 2, + "doorbell_pic": "", + "siren_switch": false, + "basic_device_volume": 1, + "motion_tracking": true, + "device_restart": false, + "humanoid_filter": true, + "cruise_switch": false, + "cruise_mode": 0, + "alarm_message": "**REDACTED**", + "ipc_work_mode": 0, + "initiative_message": "" + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json b/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json new file mode 100644 index 00000000000..fdfbae9fbbf --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json @@ -0,0 +1,139 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfa008a4f82a56616c69uz", + "name": "jardin Fraises", + "category": "tdq", + "product_id": "1aegphq4yfd50e6b", + "product_name": "1-433", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-09-13T12:26:55+00:00", + "create_time": "2024-09-13T12:26:55+00:00", + "update_time": "2024-09-13T12:26:55+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "relay_status": 2, + "random_time": "", + "cycle_time": "", + "switch_inching": "AAAC", + "switch_type": "button", + "remote_add": "", + "remote_list": "AA==" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json b/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json new file mode 100644 index 00000000000..e3476118f20 --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json @@ -0,0 +1,139 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bff35871a2f4430058vs8u", + "name": "Framboisiers", + "category": "tdq", + "product_id": "9htyiowaf5rtdhrv", + "product_name": "1-433", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-09-08T13:46:46+00:00", + "create_time": "2024-09-08T13:46:46+00:00", + "update_time": "2024-09-08T13:46:46+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "relay_status": 2, + "random_time": "", + "cycle_time": "", + "switch_inching": "AAAC", + "switch_type": "button", + "remote_add": "", + "remote_list": "AA==" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json b/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json index c139e79d19b..e7c79f3fb41 100644 --- a/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json +++ b/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1748383912663Y2lvlm", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json b/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json new file mode 100644 index 00000000000..1e40823b93d --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json @@ -0,0 +1,227 @@ +{ + "endpoint": "https://apigw.tuyain.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "d7ca553b5f406266350poc", + "name": "Seating side 6-ch Smart Switch ", + "category": "tdq", + "product_id": "nockvv2k39vbrxxk", + "product_name": "6 Switch Smart RetroFit Module", + "online": true, + "sub": false, + "time_zone": "+05:30", + "active_time": "2025-05-12T06:36:18+00:00", + "create_time": "2025-05-12T06:36:18+00:00", + "update_time": "2025-05-12T06:36:18+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "switch_5": { + "type": "Boolean", + "value": {} + }, + "switch_6": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_5": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_6": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "switch_5": { + "type": "Boolean", + "value": {} + }, + "switch_6": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_5": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_6": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": false, + "switch_2": true, + "switch_3": false, + "switch_4": true, + "switch_5": false, + "switch_6": true, + "countdown_1": 0, + "countdown_2": 0, + "countdown_3": 0, + "countdown_4": 0, + "countdown_5": 0, + "countdown_6": 0, + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json b/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json new file mode 100644 index 00000000000..da26a133014 --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json @@ -0,0 +1,169 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf0dc19ab84dc3627ep2un", + "name": "Socket3", + "category": "tdq", + "product_id": "pu8uhxhwcp3tgoz7", + "product_name": "Smart Plug +", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-16T18:48:20+00:00", + "create_time": "2025-01-16T18:48:20+00:00", + "update_time": "2025-01-16T18:48:20+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kW·h", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["ov_cr", "ov_vol", "ov_pwr", "ls_cr", "ls_vol", "ls_pow"] + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2381, + "test_bit": 2, + "fault": 0, + "relay_status": 2, + "random_time": "", + "cycle_time": "", + "switch_inching": "AAAC" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json b/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json new file mode 100644 index 00000000000..708764184ad --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json @@ -0,0 +1,23 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfb3c90d87dac93d2bdxn3", + "name": "Living room left", + "category": "tdq", + "product_id": "uoa3mayicscacseb", + "product_name": "Curtain switch", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-10-30T16:55:40+00:00", + "create_time": "2024-10-30T16:55:40+00:00", + "update_time": "2024-10-30T16:55:40+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tyndj_pyakuuoc.json b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json index 973cecabc0b..656c626c4fe 100644 --- a/tests/components/tuya/fixtures/tyndj_pyakuuoc.json +++ b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1753247726209KOaaPc", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json b/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json new file mode 100644 index 00000000000..0e39f713dd0 --- /dev/null +++ b/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json @@ -0,0 +1,90 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1752690839034sq255y", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf79ca977d67322eb2o68m", + "name": "X5 Zigbee Gateway", + "category": "wg2", + "product_id": "nwxr8qcu4seltoro", + "product_name": "X5", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-14T10:19:21+00:00", + "create_time": "2025-07-14T10:19:21+00:00", + "update_time": "2025-07-14T10:19:21+00:00", + "function": { + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "master_language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "french", + "italian", + "german", + "spanish", + "portuguese", + "russian", + "japanese" + ] + } + } + }, + "status_range": { + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "master_information": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "master_language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "french", + "italian", + "german", + "spanish", + "portuguese", + "russian", + "japanese" + ] + } + } + }, + "status": { + "master_state": "normal", + "master_information": "", + "factory_reset": false, + "master_language": "chinese_simplified" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_aqoouq7x.json b/tests/components/tuya/fixtures/wk_aqoouq7x.json index 2c162a1a514..900ae356f38 100644 --- a/tests/components/tuya/fixtures/wk_aqoouq7x.json +++ b/tests/components/tuya/fixtures/wk_aqoouq7x.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1749538552551GHfV17", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json b/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json index e96389ca215..002b0609464 100644 --- a/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json +++ b/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "xxxxxxxxxxxxxxxxxxx", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json b/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json index 06d07a4c506..2929872f4c1 100644 --- a/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json +++ b/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json @@ -1,10 +1,9 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "17150293164666xhFUk", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf316b8707b061f044th18", "name": "NP DownStairs North", "category": "wsdcg", diff --git a/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json b/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json index f50aab00a26..a7ab15a4511 100644 --- a/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json +++ b/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json b/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json new file mode 100644 index 00000000000..8b1cff0c773 --- /dev/null +++ b/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json @@ -0,0 +1,148 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf3d16d38b17d7034ddxd4", + "name": "Rainwater Tank Level", + "category": "ywcgq", + "product_id": "h8lvyoahr6s6aybf", + "product_name": "Tank A Level", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-31T09:55:19+00:00", + "create_time": "2025-05-31T09:55:19+00:00", + "update_time": "2025-05-31T09:55:19+00:00", + "function": { + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "upper_switch": { + "type": "Boolean", + "value": {} + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 3000, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2700, + "scale": 3, + "step": 1 + } + } + }, + "status_range": { + "liquid_state": { + "type": "Enum", + "value": { + "range": ["normal", "lower_alarm", "upper_alarm"] + } + }, + "liquid_depth": { + "type": "Integer", + "value": { + "unit": "m", + "min": 0, + "max": 10000, + "scale": 3, + "step": 1 + } + }, + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "upper_switch": { + "type": "Boolean", + "value": {} + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 3000, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2700, + "scale": 3, + "step": 1 + } + }, + "liquid_level_percent": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "liquid_state": "normal", + "liquid_depth": 455, + "max_set": 90, + "mini_set": 10, + "upper_switch": false, + "installation_height": 1350, + "liquid_depth_max": 100, + "liquid_level_percent": 36 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json index f724ffe164f..52eda664345 100644 --- a/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json +++ b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json @@ -1,20 +1,22 @@ { - "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", + "endpoint": "https://openapi.tuyaus.com", + "auth_type": 0, + "country_code": "1", + "app_type": "tuyaSmart", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf27a4********368f4w", - "name": "Nivel del tanque A", + "name": "House Water Level", + "model": "EPT_Ultrasonic level sensor", "category": "ywcgq", "product_id": "wtzwyhkev3b4ubns", "product_name": "Tank A Level", "online": true, "sub": false, - "time_zone": "+01:00", - "active_time": "2024-01-05T10:22:24+00:00", - "create_time": "2024-01-05T10:22:24+00:00", - "update_time": "2024-01-05T10:22:24+00:00", + "time_zone": "-06:00", + "active_time": "2023-11-02T22:48:03+00:00", + "create_time": "2023-11-02T22:48:03+00:00", + "update_time": "2023-11-09T13:32:38+00:00", "function": { "max_set": { "type": "Integer", @@ -126,14 +128,13 @@ } }, "status": { - "liquid_state": "normal", - "liquid_depth": 77, + "liquid_state": "upper_alarm", + "liquid_depth": 42, "max_set": 100, - "mini_set": 10, - "installation_height": 980, - "liquid_depth_max": 140, - "liquid_level_percent": 97 + "mini_set": 0, + "installation_height": 560, + "liquid_depth_max": 100, + "liquid_level_percent": 100 }, - "set_up": false, - "support_local": true + "terminal_id": "REDACTED" } diff --git a/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json b/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json index 139cf814347..797ddba3587 100644 --- a/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json +++ b/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1739198173271wpFacM", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/zwjcy_myd45weu.json b/tests/components/tuya/fixtures/zwjcy_myd45weu.json new file mode 100644 index 00000000000..3ea111abb0e --- /dev/null +++ b/tests/components/tuya/fixtures/zwjcy_myd45weu.json @@ -0,0 +1,79 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf1a0431555359ce06ie0z", + "name": "Patates", + "category": "zwjcy", + "product_id": "myd45weu", + "product_name": "Soil sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T12:12:41+00:00", + "create_time": "2025-07-19T12:12:41+00:00", + "update_time": "2025-07-19T12:12:41+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": -30, + "max": 70, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "humidity": 97, + "temp_current": 22, + "temp_unit_convert": "c", + "battery_state": "low", + "battery_percentage": 20 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 7cb613ebbf2..6ae0b4997dd 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -685,6 +685,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[wg2_nwxr8qcu4seltoro][binary_sensor.x5_zigbee_gateway_problem-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.x5_zigbee_gateway_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf79ca977d67322eb2o68mmaster_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wg2_nwxr8qcu4seltoro][binary_sensor.x5_zigbee_gateway_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'X5 Zigbee Gateway Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.x5_zigbee_gateway_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][binary_sensor.smoke_detector_upstairs_smoke-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_button.ambr b/tests/components/tuya/snapshots/test_button.ambr new file mode 100644 index 00000000000..61b62e124e5 --- /dev/null +++ b/tests/components/tuya/snapshots/test_button.ambr @@ -0,0 +1,241 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_duster_cloth-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.v20_reset_duster_cloth', + '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 duster cloth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_duster_cloth', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_duster_cloth', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_duster_cloth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset duster cloth', + }), + 'context': , + 'entity_id': 'button.v20_reset_duster_cloth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_edge_brush-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.v20_reset_edge_brush', + '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 edge brush', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_edge_brush', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_edge_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_edge_brush-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset edge brush', + }), + 'context': , + 'entity_id': 'button.v20_reset_edge_brush', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_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': , + 'entity_id': 'button.v20_reset_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 filter', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_filter', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset filter', + }), + 'context': , + 'entity_id': 'button.v20_reset_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_map-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.v20_reset_map', + '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 map', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_map', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_map', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_map-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset map', + }), + 'context': , + 'entity_id': 'button.v20_reset_map', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_roll_brush-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.v20_reset_roll_brush', + '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 roll brush', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_roll_brush', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_roll_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_roll_brush-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset roll brush', + }), + 'context': , + 'entity_id': 'button.v20_reset_roll_brush', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_camera.ambr b/tests/components/tuya/snapshots/test_camera.ambr new file mode 100644 index 00000000000..e1945f03d3c --- /dev/null +++ b/tests/components/tuya/snapshots/test_camera.ambr @@ -0,0 +1,162 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][camera.cam_garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.cam_garage', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][camera.cam_garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.cam_garage?token=1', + 'friendly_name': 'CAM GARAGE', + 'model_name': 'Indoor camera ', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.cam_garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][camera.cam_porch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.cam_porch', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][camera.cam_porch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.cam_porch?token=1', + 'friendly_name': 'CAM PORCH', + 'model_name': 'Indoor cam Pan/Tilt ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.cam_porch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][camera.c9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.c9', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf3f8b448bbc123e29oghf', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][camera.c9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.c9?token=1', + 'friendly_name': 'C9', + 'model_name': 'Security Camera', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.c9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'recording', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index aa592b25520..0c556a90494 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -1,4 +1,208 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][cover.lounge_dark_blind_curtain-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.lounge_dark_blind_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.mocked_device_idcontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][cover.lounge_dark_blind_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'curtain', + 'friendly_name': 'Lounge Dark Blind Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.lounge_dark_blind_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cl_cpbo62rn][cover.blinds_curtain-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.blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.bf216113c71bf01a18jtl0control', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_cpbo62rn][cover.blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 36, + 'device_class': 'curtain', + 'friendly_name': 'blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cl_ebt12ypvexnixvtf][cover.kitchen_blinds_blind-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.kitchen_blinds_blind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Blind', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'blind', + 'unique_id': 'tuya.mocked_device_idswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_ebt12ypvexnixvtf][cover.kitchen_blinds_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'blind', + 'friendly_name': 'Kitchen Blinds Blind', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_blinds_blind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cl_qqdxfdht][cover.bedroom_blinds_curtain-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.bedroom_blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.bfb9c4958fd06d141djpqacontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_qqdxfdht][cover.bedroom_blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'curtain', + 'friendly_name': 'bedroom blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.bedroom_blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_platform_setup_and_discovery[cl_zah67ekd][cover.kitchen_blinds_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 69eb1b467e9..7532023860b 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -53,6 +53,56 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][fan.dryfix-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dryfix', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.28403630e8db84b7a963', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][fan.dryfix-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DryFix', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dryfix', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -153,6 +203,177 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][fan.ceiling_fan_with_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'normal', + 'sleep', + 'nature', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ceiling_fan_with_light', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.XXX', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][fan.ceiling_fan_with_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': 'reverse', + 'friendly_name': 'Ceiling Fan With Light', + 'percentage': None, + 'percentage_step': 16.666666666666668, + 'preset_mode': 'normal', + 'preset_modes': list([ + 'normal', + 'sleep', + 'nature', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.ceiling_fan_with_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[fs_ibytpo6fpnugft1c][fan.ventilador_cama-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ventilador_cama', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.10706550a4e57c88b93a', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_ibytpo6fpnugft1c][fan.ventilador_cama-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ventilador Cama', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.ventilador_cama', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][fan.hl400-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hl400', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.152027113c6105cce49c', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][fan.hl400-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400', + 'percentage': None, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hl400', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][fan.bree-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index 25bb1799dc8..33034e3f6e7 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -54,6 +54,61 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][humidifier.dryfix-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dryfix', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.28403630e8db84b7a963switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][humidifier.dryfix-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'DryFix', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dryfix', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][humidifier.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index b5dca58f8e7..4f2f22ddf2b 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -2022,6 +2022,87 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][light.ceiling_fan_with_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.ceiling_fan_with_light', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.XXXlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][light.ceiling_fan_with_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'Ceiling Fan With Light', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.ceiling_fan_with_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[gyd_lgekqfxdabipm3tn][light.colorful_pir_night_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 9a04b9dd78c..b5d6224ecea 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -469,6 +469,180 @@ 'state': '3.0', }) # --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 92.5, + 'min': 25.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.sous_vide_cook_temperature', + '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': 'Cook temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_temperature', + 'unique_id': 'tuya.bff434eca843ffc9afmthvcook_temperature', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Cook temperature', + 'max': 92.5, + 'min': 25.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.sous_vide_cook_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5999.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.sous_vide_cook_time', + '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': 'Cook time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time', + 'unique_id': 'tuya.bff434eca843ffc9afmthvcook_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Cook time', + 'max': 5999.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.sous_vide_cook_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][number.v20_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.v20_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtvolume_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][number.v20_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Volume', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.v20_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '95.0', + }) +# --- # name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][number.siren_veranda_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -527,6 +701,64 @@ 'state': '10.0', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][number.c9_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.c9_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_device_volume', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][number.c9_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Volume', + 'max': 10.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.c9_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -585,3 +817,471 @@ 'state': '-1.5', }) # --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_maximum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_alarm_maximum', + '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': 'Alarm maximum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_maximum', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4max_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_maximum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Alarm maximum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_alarm_maximum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_minimum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_alarm_minimum', + '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': 'Alarm minimum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_minimum', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4mini_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_minimum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Alarm minimum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_alarm_minimum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_installation_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3.0, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_installation_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installation height', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'installation_height', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4installation_height', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_installation_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Installation height', + 'max': 3.0, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_installation_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.35', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_maximum_liquid_depth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.7, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum liquid depth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_liquid_depth', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_depth_max', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_maximum_liquid_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Maximum liquid depth', + 'max': 2.7, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_maximum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_alarm_maximum', + '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': 'Alarm maximum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_maximum', + 'unique_id': 'tuya.mocked_device_idmax_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_maximum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Alarm maximum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.house_water_level_alarm_maximum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_minimum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_alarm_minimum', + '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': 'Alarm minimum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_minimum', + 'unique_id': 'tuya.mocked_device_idmini_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_minimum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Alarm minimum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.house_water_level_alarm_minimum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_installation_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.5, + 'min': 0.2, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_installation_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installation height', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'installation_height', + 'unique_id': 'tuya.mocked_device_idinstallation_height', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_installation_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Installation height', + 'max': 2.5, + 'min': 0.2, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.house_water_level_installation_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.56', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_maximum_liquid_depth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.4, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_maximum_liquid_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum liquid depth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_liquid_depth', + 'unique_id': 'tuya.mocked_device_idliquid_depth_max', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_maximum_liquid_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Maximum liquid depth', + 'max': 2.4, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.house_water_level_maximum_liquid_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 943e230b7cd..98e3174b077 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1,4 +1,61 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[cl_cpbo62rn][select.blinds_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'morning', + 'night', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.blinds_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'curtain_mode', + 'unique_id': 'tuya.bf216113c71bf01a18jtl0mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_cpbo62rn][select.blinds_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'blinds Mode', + 'options': list([ + 'morning', + 'night', + ]), + }), + 'context': , + 'entity_id': 'select.blinds_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'morning', + }) +# --- # name: test_platform_setup_and_discovery[cl_zah67ekd][select.kitchen_blinds_motor_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -414,6 +471,69 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][select.ceiling_fan_with_light_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '4h', + '8h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ceiling_fan_with_light_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.XXXcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][select.ceiling_fan_with_light_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ceiling Fan With Light Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '4h', + '8h', + ]), + }), + 'context': , + 'entity_id': 'select.ceiling_fan_with_light_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][select.bree_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -479,6 +599,126 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart', + 'zone', + 'pose', + 'part', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.v20_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_mode', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Mode', + 'options': list([ + 'smart', + 'zone', + 'pose', + 'part', + ]), + }), + 'context': , + 'entity_id': 'select.v20_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_water_tank_adjustment-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'middle', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.v20_water_tank_adjustment', + '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': 'Water tank adjustment', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_cistern', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtcistern', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_water_tank_adjustment-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Water tank adjustment', + 'options': list([ + 'low', + 'middle', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.v20_water_tank_adjustment', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- # name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][select.siren_veranda_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -945,6 +1185,297 @@ 'state': 'unknown', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_ipc_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.c9_ipc_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': 'IPC mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ipc_work_mode', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfipc_work_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_ipc_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 IPC mode', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.c9_ipc_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.c9_motion_detection_sensitivity', + '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': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.c9_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.c9_record_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': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.c9_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][select.jardin_fraises_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.jardin_fraises_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.bfa008a4f82a56616c69uzrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][select.jardin_fraises_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'jardin Fraises Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.jardin_fraises_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][select.framboisiers_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.framboisiers_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.bff35871a2f4430058vs8urelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][select.framboisiers_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Framboisiers Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.framboisiers_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][select.4_433_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1004,3 +1535,62 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][select.socket3_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.socket3_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.bf0dc19ab84dc3627ep2unrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][select.socket3_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Socket3 Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.socket3_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index e8b9900185e..fade1fcbc2b 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][sensor.lounge_dark_blind_last_operation_duration-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.lounge_dark_blind_last_operation_duration', + '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': 'Last operation duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_operation_duration', + 'unique_id': 'tuya.mocked_device_idtime_total', + 'unit_of_measurement': 'ms', + }) +# --- +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][sensor.lounge_dark_blind_last_operation_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lounge Dark Blind Last operation duration', + 'unit_of_measurement': 'ms', + }), + 'context': , + 'entity_id': 'sensor.lounge_dark_blind_last_operation_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25400.0', + }) +# --- # name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1876,6 +1925,159 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_current_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.sous_vide_current_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_temperature', + 'unique_id': 'tuya.bff434eca843ffc9afmthvtemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_current_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Sous Vide Current temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sous_vide_current_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_remaining_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.sous_vide_remaining_time', + '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': 'Remaining time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'tuya.bff434eca843ffc9afmthvremain_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sous_vide_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sous_vide_status', + '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': 'Status', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sous_vide_status', + 'unique_id': 'tuya.bff434eca843ffc9afmthvstatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Status', + }), + 'context': , + 'entity_id': 'sensor.sous_vide_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][sensor.rat_trap_hedge_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2025,6 +2227,62 @@ 'state': 'middle', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-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.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure', + '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': 'Air pressure', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_pressure', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzatmospheric_pressture', + 'unit_of_measurement': 'hPa', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Air pressure', + 'state_class': , + 'unit_of_measurement': 'hPa', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1004.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2179,6 +2437,218 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_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.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_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': 'Outdoor humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-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.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-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.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-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.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 3', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2235,6 +2705,174 @@ 'state': '-40.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-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.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_1', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.3', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-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.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_2', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.2', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-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.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_3', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-40.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2291,6 +2929,65 @@ 'state': '24.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-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.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed', + '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': 'Wind speed', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzwindspeed_avg', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2556,6 +3253,753 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_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.v20_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtelectricity_left', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'V20 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.v20_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_area-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.v20_cleaning_area', + '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': 'Cleaning area', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_area', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtclean_area', + 'unit_of_measurement': '㎡', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Cleaning area', + 'state_class': , + 'unit_of_measurement': '㎡', + }), + 'context': , + 'entity_id': 'sensor.v20_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_time-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.v20_cleaning_time', + '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': 'Cleaning time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_time', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtclean_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Cleaning time', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_duster_cloth_lifetime-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.v20_duster_cloth_lifetime', + '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': 'Duster cloth lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duster_cloth_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtduster_cloth', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_duster_cloth_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Duster cloth lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_duster_cloth_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9000.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_filter_lifetime-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.v20_filter_lifetime', + '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': 'Filter lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtfilter', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_filter_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Filter lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_filter_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8956.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_rolling_brush_lifetime-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.v20_rolling_brush_lifetime', + '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': 'Rolling brush lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rolling_brush_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtroll_brush', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_rolling_brush_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Rolling brush lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_rolling_brush_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17948.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_side_brush_lifetime-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.v20_side_brush_lifetime', + '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': 'Side brush lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtedge_brush', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_side_brush_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Side brush lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_side_brush_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8944.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_area-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.v20_total_cleaning_area', + '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': 'Total cleaning area', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_area', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_area', + 'unit_of_measurement': '㎡', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning area', + 'state_class': , + 'unit_of_measurement': '㎡', + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_time-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.v20_total_cleaning_time', + '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': 'Total cleaning time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning time', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_times-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.v20_total_cleaning_times', + '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': 'Total cleaning times', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_times', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_times-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning times', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_times', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][sensor.c9_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.c9_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfwireless_electricity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][sensor.c9_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'C9 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.c9_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80.0', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_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.socket3_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.bf0dc19ab84dc3627ep2uncur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Socket3 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket3_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_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.socket3_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.bf0dc19ab84dc3627ep2uncur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Socket3 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.socket3_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_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.socket3_voltage', + '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': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.bf0dc19ab84dc3627ep2uncur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Socket3 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket3_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3026,6 +4470,318 @@ 'state': 'low', }) # --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_distance-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.rainwater_tank_level_distance', + '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': 'Distance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'depth', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_depth', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Distance', + 'state_class': , + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.455', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_level-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.rainwater_tank_level_liquid_level', + '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': 'Liquid level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_level', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_level_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Liquid level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_liquid_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rainwater_tank_level_liquid_state', + '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': 'Liquid state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_state', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Liquid state', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_liquid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_distance-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.house_water_level_distance', + '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': 'Distance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'depth', + 'unique_id': 'tuya.mocked_device_idliquid_depth', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Distance', + 'state_class': , + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.42', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_level-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.house_water_level_liquid_level', + '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': 'Liquid level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_level', + 'unique_id': 'tuya.mocked_device_idliquid_level_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Liquid level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_liquid_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.house_water_level_liquid_state', + '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': 'Liquid state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_state', + 'unique_id': 'tuya.mocked_device_idliquid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Liquid state', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_liquid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'upper_alarm', + }) +# --- # name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3194,3 +4950,213 @@ 'state': '233.8', }) # --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_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.patates_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf1a0431555359ce06ie0zbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Patates Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.patates_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.patates_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bf1a0431555359ce06ie0zbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Patates Battery state', + }), + 'context': , + 'entity_id': 'sensor.patates_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_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.patates_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bf1a0431555359ce06ie0zhumidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Patates Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.patates_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_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.patates_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bf1a0431555359ce06ie0ztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Patates Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.patates_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr index 5c46c2bbd19..876db171c7b 100644 --- a/tests/components/tuya/snapshots/test_siren.ambr +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -97,3 +97,52 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][siren.c9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.c9', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfsiren_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][siren.c9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.c9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 1b90c21bb46..d483d852f1a 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1,4 +1,52 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][switch.lounge_dark_blind_reverse-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': , + 'entity_id': 'switch.lounge_dark_blind_reverse', + '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': 'Reverse', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reverse', + 'unique_id': 'tuya.mocked_device_idcontrol_back', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][switch.lounge_dark_blind_reverse-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lounge Dark Blind Reverse', + }), + 'context': , + 'entity_id': 'switch.lounge_dark_blind_reverse', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][switch.dehumidifer_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -970,6 +1018,198 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.hl400_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.152027113c6105cce49clock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Child lock', + }), + 'context': , + 'entity_id': 'switch.hl400_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_ionizer-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': , + 'entity_id': 'switch.hl400_ionizer', + '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': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.152027113c6105cce49canion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Ionizer', + }), + 'context': , + 'entity_id': 'switch.hl400_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_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': 'switch', + 'entity_category': None, + 'entity_id': 'switch.hl400_power', + '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': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.152027113c6105cce49cswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Power', + }), + 'context': , + 'entity_id': 'switch.hl400_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_uv_sterilization-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': , + 'entity_id': 'switch.hl400_uv_sterilization', + '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': 'UV sterilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_sterilization', + 'unique_id': 'tuya.152027113c6105cce49cuv', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_uv_sterilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 UV sterilization', + }), + 'context': , + 'entity_id': 'switch.hl400_uv_sterilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][switch.bree_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1162,6 +1402,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][switch.sous_vide_start-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': , + 'entity_id': 'switch.sous_vide_start', + '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': 'Start', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'tuya.bff434eca843ffc9afmthvstart', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][switch.sous_vide_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Start', + }), + 'context': , + 'entity_id': 'switch.sous_vide_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[pc_t2afic7i3v1bwhfp][switch.bubbelbad_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1406,6 +1694,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][switch.v20_do_not_disturb-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': , + 'entity_id': 'switch.v20_do_not_disturb', + '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': 'Do not disturb', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'do_not_disturb', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtswitch_disturb', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][switch.v20_do_not_disturb-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Do not disturb', + }), + 'context': , + 'entity_id': 'switch.v20_do_not_disturb', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[sfkzq_o6dagifntoafakst][switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1934,6 +2270,440 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_flip-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': , + 'entity_id': 'switch.c9_flip', + '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': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Flip', + }), + 'context': , + 'entity_id': 'switch.c9_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.c9_motion_alarm', + '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': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion alarm', + }), + 'context': , + 'entity_id': 'switch.c9_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_recording-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': , + 'entity_id': 'switch.c9_motion_recording', + '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': 'Motion recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_recording', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion recording', + }), + 'context': , + 'entity_id': 'switch.c9_motion_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_tracking-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': , + 'entity_id': 'switch.c9_motion_tracking', + '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': 'Motion tracking', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_tracking', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion tracking', + }), + 'context': , + 'entity_id': 'switch.c9_motion_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_time_watermark-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': , + 'entity_id': 'switch.c9_time_watermark', + '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': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Time watermark', + }), + 'context': , + 'entity_id': 'switch.c9_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_video_recording-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': , + 'entity_id': 'switch.c9_video_recording', + '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': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Video recording', + }), + 'context': , + 'entity_id': 'switch.c9_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_wide_dynamic_range-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': , + 'entity_id': 'switch.c9_wide_dynamic_range', + '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': 'Wide dynamic range', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wide_dynamic_range', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_wdr', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_wide_dynamic_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Wide dynamic range', + }), + 'context': , + 'entity_id': 'switch.c9_wide_dynamic_range', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][switch.jardin_fraises_switch_1-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.jardin_fraises_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.bfa008a4f82a56616c69uzswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][switch.jardin_fraises_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'jardin Fraises Switch 1', + }), + 'context': , + 'entity_id': 'switch.jardin_fraises_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][switch.framboisiers_switch_1-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.framboisiers_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.bff35871a2f4430058vs8uswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][switch.framboisiers_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Framboisiers Switch 1', + }), + 'context': , + 'entity_id': 'switch.framboisiers_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2130,6 +2900,397 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.d7ca553b5f406266350pocchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seating side 6-ch Smart Switch Child lock', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_1-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.seating_side_6_ch_smart_switch_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 1', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_2-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.seating_side_6_ch_smart_switch_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 2', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_3-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.seating_side_6_ch_smart_switch_switch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 3', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_4-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.seating_side_6_ch_smart_switch_switch_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 4', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 4', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_5-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.seating_side_6_ch_smart_switch_switch_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': 'Switch 5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 5', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_6-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.seating_side_6_ch_smart_switch_switch_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 6', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 6', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][switch.socket3_switch_1-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.socket3_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.bf0dc19ab84dc3627ep2unswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][switch.socket3_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Socket3 Switch 1', + }), + 'context': , + 'entity_id': 'switch.socket3_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[tyndj_pyakuuoc][switch.solar_zijpad_energy_saving-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_vacuum.ambr b/tests/components/tuya/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..bc9ecd197d4 --- /dev/null +++ b/tests/components/tuya/snapshots/test_vacuum.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][vacuum.v20-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_speed_list': list([ + 'gentle', + 'normal', + 'strong', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.v20', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmt', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][vacuum.v20-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'fan_speed': 'strong', + 'fan_speed_list': list([ + 'gentle', + 'normal', + 'strong', + ]), + 'friendly_name': 'V20', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.v20', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- diff --git a/tests/components/tuya/test_button.py b/tests/components/tuya/test_button.py new file mode 100644 index 00000000000..b8c6dda4afa --- /dev/null +++ b/tests/components/tuya/test_button.py @@ -0,0 +1,57 @@ +"""Test Tuya button platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.BUTTON in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.BUTTON not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_camera.py b/tests/components/tuya/test_camera.py new file mode 100644 index 00000000000..25bfe57ea0c --- /dev/null +++ b/tests/components/tuya/test_camera.py @@ -0,0 +1,73 @@ +"""Test Tuya camera platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def mock_getrandbits(): + """Mock camera access token which normally is randomized.""" + with patch( + "homeassistant.components.camera.SystemRandom.getrandbits", + return_value=1, + ): + yield + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.CAMERA in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.CAMERA not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_vacuum.py b/tests/components/tuya/test_vacuum.py new file mode 100644 index 00000000000..1caf298f3c4 --- /dev/null +++ b/tests/components/tuya/test_vacuum.py @@ -0,0 +1,91 @@ +"""Test Tuya vacuum platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.VACUUM in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.VACUUM not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["sd_lr33znaodtyarrrz"], +) +async def test_return_home( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test return home service.""" + # Based on #141278 + entity_id = "vacuum.v20" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, + { + "entity_id": entity_id, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch_charge", "value": True}] + ) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index b513a04a40b..846b3657bb2 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -721,10 +721,10 @@ async def test_subscribe_conditions( ) -> None: """Test condition_platforms/subscribe command.""" sun_condition_descriptions = """ - sun: {} + _: {} """ device_automation_condition_descriptions = """ - device: {} + _device: {} """ def _load_yaml(fname, secrets=None): @@ -806,10 +806,10 @@ async def test_subscribe_triggers( ) -> None: """Test trigger_platforms/subscribe command.""" sun_trigger_descriptions = """ - sun: {} + _: {} """ tag_trigger_descriptions = """ - tag: {} + _: {} """ def _load_yaml(fname, secrets=None): @@ -2738,10 +2738,7 @@ async def test_validate_config_works( "entity_id": "hello.world", "state": "paulus", }, - ( - "Invalid condition \"non_existing\" specified {'condition': " - "'non_existing', 'entity_id': 'hello.world', 'state': 'paulus'}" - ), + 'Invalid condition "non_existing" specified', ), # Raises HomeAssistantError ( diff --git a/tests/components/zimi/common.py b/tests/components/zimi/common.py new file mode 100644 index 00000000000..13582b3d42c --- /dev/null +++ b/tests/components/zimi/common.py @@ -0,0 +1,81 @@ +"""Common items for testing the zimi component.""" + +from unittest.mock import MagicMock, create_autospec, patch + +from zcc.device import ControlPointDevice + +from homeassistant.components.zimi.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +DEVICE_INFO = { + "id": "test-device-id", + "name": "unknown", + "manufacturer": "Zimi", + "model": "Controller XYZ", + "hwVersion": "2.2.2", + "fwVersion": "3.3.3", +} + +ENTITY_INFO = { + "id": "test-entity-id", + "name": "Test Entity Name", + "room": "Test Entity Room", + "type": "unknown", +} + +INPUT_HOST = "192.168.1.100" +INPUT_PORT = 5003 + + +def mock_api_device( + device_name: str | None = None, + entity_type: str | None = None, +) -> MagicMock: + """Mock a Zimi ControlPointDevice which is used in the zcc API with defaults.""" + + mock_api_device = create_autospec(ControlPointDevice) + + mock_api_device.identifier = ENTITY_INFO["id"] + mock_api_device.room = ENTITY_INFO["room"] + mock_api_device.name = ENTITY_INFO["name"] + mock_api_device.type = entity_type or ENTITY_INFO["type"] + + mock_manfacture_info = MagicMock() + mock_manfacture_info.identifier = DEVICE_INFO["id"] + mock_manfacture_info.manufacturer = DEVICE_INFO["manufacturer"] + mock_manfacture_info.model = DEVICE_INFO["model"] + mock_manfacture_info.name = device_name or DEVICE_INFO["name"] + mock_manfacture_info.hwVersion = DEVICE_INFO["hwVersion"] + mock_manfacture_info.firmwareVersion = DEVICE_INFO["fwVersion"] + + mock_api_device.manufacture_info = mock_manfacture_info + + mock_api_device.brightness = 0 + mock_api_device.percentage = 0 + + return mock_api_device + + +async def setup_platform( + hass: HomeAssistant, + platform: str, +) -> MockConfigEntry: + """Set up the specified Zimi platform.""" + + if not platform: + raise ValueError("Platform must be specified") + + mock_config = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: INPUT_HOST, CONF_PORT: INPUT_PORT} + ) + mock_config.add_to_hass(hass) + + with patch("homeassistant.components.zimi.PLATFORMS", [platform]): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return mock_config diff --git a/tests/components/zimi/conftest.py b/tests/components/zimi/conftest.py new file mode 100644 index 00000000000..b26c2f89784 --- /dev/null +++ b/tests/components/zimi/conftest.py @@ -0,0 +1,30 @@ +"""Test fixtures for Zimi component.""" + +from unittest.mock import MagicMock, patch + +import pytest + +INPUT_MAC = "aa:bb:cc:dd:ee:ff" + + +API_INFO = { + "brand": "Zimi", + "network_name": "Test Network", + "firmware_version": "1.1.1", +} + + +@pytest.fixture +def mock_api(): + """Mock the API with defaults.""" + with patch("homeassistant.components.zimi.async_connect_to_controller") as mock: + mock_api = mock.return_value + mock_api.describe = MagicMock() + mock_api.disconnect = MagicMock() + mock_api.connect.return_value = True + mock_api.mac = INPUT_MAC + mock_api.brand = API_INFO["brand"] + mock_api.network_name = API_INFO["network_name"] + mock_api.firmware_version = API_INFO["firmware_version"] + + yield mock_api diff --git a/tests/components/zimi/snapshots/test_cover.ambr b/tests/components/zimi/snapshots/test_cover.ambr new file mode 100644 index 00000000000..66d74f36771 --- /dev/null +++ b/tests/components/zimi/snapshots/test_cover.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_cover_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'garage', + 'friendly_name': 'Cover Controller Test Entity Name', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'opening', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_fan.ambr b/tests/components/zimi/snapshots/test_fan.ambr new file mode 100644 index 00000000000..6b3f226b4f9 --- /dev/null +++ b/tests/components/zimi/snapshots/test_fan.ambr @@ -0,0 +1,19 @@ +# serializer version: 1 +# name: test_fan_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fan Controller Test Entity Name', + 'percentage': 1, + 'percentage_step': 12.5, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.fan_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_light.ambr b/tests/components/zimi/snapshots/test_light.ambr new file mode 100644 index 00000000000..372e2c937ca --- /dev/null +++ b/tests/components/zimi/snapshots/test_light.ambr @@ -0,0 +1,38 @@ +# serializer version: 1 +# name: test_dimmer_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 0, + 'color_mode': , + 'friendly_name': 'Light Controller Test Entity Name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Light Controller Test Entity Name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_switch.ambr b/tests/components/zimi/snapshots/test_switch.ambr new file mode 100644 index 00000000000..c96fc99b908 --- /dev/null +++ b/tests/components/zimi/snapshots/test_switch.ambr @@ -0,0 +1,14 @@ +# serializer version: 1 +# name: test_switch_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Switch Controller Test Entity Name', + }), + 'context': , + 'entity_id': 'switch.switch_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/test_cover.py b/tests/components/zimi/test_cover.py new file mode 100644 index 00000000000..68809af49e6 --- /dev/null +++ b/tests/components/zimi/test_cover.py @@ -0,0 +1,77 @@ +"""Test the Zimi cover entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_cover_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests cover entity.""" + + device_name = "Cover Controller" + entity_key = "cover.cover_controller_test_entity_name" + entity_type = Platform.COVER + + mock_api.doors = [mock_api_device(device_name=device_name, entity_type=entity_type)] + + await setup_platform(hass, entity_type) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert ( + entity.supported_features + == CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_CLOSE_COVER in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_CLOSE_COVER, + {"entity_id": entity_key}, + blocking=True, + ) + assert mock_api.doors[0].close_door.called + + assert SERVICE_OPEN_COVER in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_OPEN_COVER, + {"entity_id": entity_key}, + blocking=True, + ) + assert mock_api.doors[0].open_door.called + + assert SERVICE_SET_COVER_POSITION in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_key, "position": 50}, + blocking=True, + ) + assert mock_api.doors[0].open_to_percentage.called diff --git a/tests/components/zimi/test_fan.py b/tests/components/zimi/test_fan.py new file mode 100644 index 00000000000..ed87b32a61f --- /dev/null +++ b/tests/components/zimi/test_fan.py @@ -0,0 +1,75 @@ +"""Test the Zimi fan entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import FanEntityFeature +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_fan_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests fan entity.""" + + device_name = "Fan Controller" + entity_key = "fan.fan_controller_test_entity_name" + entity_type = Platform.FAN + + mock_api.fans = [mock_api_device(device_name=device_name, entity_type=entity_type)] + + await setup_platform(hass, entity_type) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert ( + entity.supported_features + == FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.fans[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.fans[0].turn_off.called + + assert "set_percentage" in services[entity_type] + await hass.services.async_call( + entity_type, + "set_percentage", + {"entity_id": entity_key, "percentage": 50}, + blocking=True, + ) + assert mock_api.fans[0].set_fanspeed.called diff --git a/tests/components/zimi/test_light.py b/tests/components/zimi/test_light.py new file mode 100644 index 00000000000..7716a6368fe --- /dev/null +++ b/tests/components/zimi/test_light.py @@ -0,0 +1,119 @@ +"""Test the Zimi light entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ColorMode +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_light_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests lights entity.""" + + device_name = "Light Controller" + entity_key = "light.light_controller_test_entity_name" + entity_type = "light" + + mock_api.lights = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.LIGHT) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert entity.capabilities == { + "supported_color_modes": [ColorMode.ONOFF], + } + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].turn_off.called + + +async def test_dimmer_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests dimmer entity.""" + + device_name = "Light Controller" + entity_key = "light.light_controller_test_entity_name" + entity_type = "dimmer" + entity_type_override = "light" + + mock_api.lights = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.LIGHT) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert entity.capabilities == { + "supported_color_modes": [ColorMode.BRIGHTNESS], + } + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type_override] + + await hass.services.async_call( + entity_type_override, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].set_brightness.called + + assert SERVICE_TURN_OFF in services[entity_type_override] + + await hass.services.async_call( + entity_type_override, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].set_brightness.called diff --git a/tests/components/zimi/test_switch.py b/tests/components/zimi/test_switch.py new file mode 100644 index 00000000000..2464757e7b6 --- /dev/null +++ b/tests/components/zimi/test_switch.py @@ -0,0 +1,60 @@ +"""Test the Zimi switch entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_switch_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests switch entity.""" + + device_name = "Switch Controller" + entity_key = "switch.switch_controller_test_entity_name" + entity_type = "switch" + + mock_api.outlets = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.SWITCH) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.outlets[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.outlets[0].turn_off.called diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 3c39868ff93..1aaa9013d87 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -497,17 +497,17 @@ async def test_on_node_added_ready( ) -async def test_on_node_added_preprovisioned( +async def test_check_pre_provisioned_device_update_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6_state, - client, - integration, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, ) -> None: - """Test node added event with a preprovisioned device.""" + """Test check pre-provisioned device that should update the device.""" dsk = "test" node = Node(client, deepcopy(multisensor_6_state)) - device = device_registry.async_get_or_create( + pre_provisioned_device = device_registry.async_get_or_create( config_entry_id=integration.entry_id, identifiers={(DOMAIN, f"provision_{dsk}")}, ) @@ -515,7 +515,7 @@ async def test_on_node_added_preprovisioned( { "dsk": dsk, "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], - "device_id": device.id, + "device_id": pre_provisioned_device.id, } ) with patch( @@ -526,14 +526,60 @@ async def test_on_node_added_preprovisioned( client.driver.controller.emit("node added", event) await hass.async_block_till_done() - device = device_registry.async_get(device.id) + device = device_registry.async_get(pre_provisioned_device.id) assert device assert device.identifiers == { get_device_id(client.driver, node), get_device_id_ext(client.driver, node), } assert device.sw_version == node.firmware_version - # There should only be the controller and the preprovisioned device + # There should only be the controller and the pre-provisioned device + assert len(device_registry.devices) == 2 + + +async def test_check_pre_provisioned_device_remove_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test check pre-provisioned device that should remove the device.""" + dsk = "test" + driver = client.driver + node = Node(client, deepcopy(multisensor_6_state)) + pre_provisioned_device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, f"provision_{dsk}")}, + ) + extended_identifier = get_device_id_ext(driver, node) + assert extended_identifier + existing_device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={ + get_device_id(driver, node), + extended_identifier, + }, + ) + provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": dsk, + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": pre_provisioned_device.id, + } + ) + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entry", + side_effect=lambda id: provisioning_entry if id == node.node_id else None, + ): + event = {"node": node} + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + assert not device_registry.async_get(pre_provisioned_device.id) + assert device_registry.async_get(existing_device.id) + + # There should only be the controller and the existing device assert len(device_registry.devices) == 2 diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 02675544644..7b00a9d0eef 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -977,7 +977,7 @@ async def test_zwave_js_event_invalid_config_entry_id( async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: """Test invalid trigger configs.""" with pytest.raises(vol.Invalid): - await TRIGGERS[f"{DOMAIN}.event"].async_validate_trigger_config( + await TRIGGERS["event"].async_validate_config( hass, { "platform": f"{DOMAIN}.event", @@ -988,7 +988,7 @@ async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: ) with pytest.raises(vol.Invalid): - await TRIGGERS[f"{DOMAIN}.value_updated"].async_validate_trigger_config( + await TRIGGERS["value_updated"].async_validate_config( hass, { "platform": f"{DOMAIN}.value_updated", @@ -1026,7 +1026,7 @@ async def test_zwave_js_trigger_config_entry_unloaded( await hass.config_entries.async_unload(integration.entry_id) # Test full validation for both events - assert await TRIGGERS[f"{DOMAIN}.value_updated"].async_validate_trigger_config( + assert await TRIGGERS["value_updated"].async_validate_config( hass, { "platform": f"{DOMAIN}.value_updated", @@ -1036,7 +1036,7 @@ async def test_zwave_js_trigger_config_entry_unloaded( }, ) - assert await TRIGGERS[f"{DOMAIN}.event"].async_validate_trigger_config( + assert await TRIGGERS["event"].async_validate_config( hass, { "platform": f"{DOMAIN}.event", diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index fbe0a8bbea7..d7243268b9e 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -269,7 +269,7 @@ async def test_update_entity_sleep( zen_31: Node, integration: MockConfigEntry, ) -> None: - """Test update occurs when device is asleep after it wakes up.""" + """Test update occurs when device is asleep.""" event = Event( "sleep", data={"source": "node", "event": "sleep", "nodeId": zen_31.node_id}, @@ -283,29 +283,13 @@ async def test_update_entity_sleep( await hass.async_block_till_done() # Two nodes in total, the controller node and the zen_31 node. - # The zen_31 node is asleep, - # so we should only check for updates for the controller node. - assert client.async_send_command.call_count == 1 - args = client.async_send_command.call_args[0][0] - assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == 1 - - client.async_send_command.reset_mock() - - event = Event( - "wake up", - data={"source": "node", "event": "wake up", "nodeId": zen_31.node_id}, - ) - zen_31.receive_event(event) - await hass.async_block_till_done() - - # Now that the zen_31 node is awake we can check for updates for it. - # The controller node has already been checked, - # so won't get another check now. - assert client.async_send_command.call_count == 1 - args = client.async_send_command.call_args[0][0] - assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == 94 + # We should check for updates for both nodes, including the sleeping one + # since the firmware check no longer requires device communication first. + assert client.async_send_command.call_count == 2 + # Check calls were made for both nodes + call_args = [call[0][0] for call in client.async_send_command.call_args_list] + assert any(args["nodeId"] == 1 for args in call_args) # Controller node + assert any(args["nodeId"] == 94 for args in call_args) # zen_31 node async def test_update_entity_dead( @@ -1158,28 +1142,3 @@ async def test_update_entity_no_latest_version( assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] is None assert state.attributes[ATTR_LATEST_VERSION] == latest_version - - -async def test_update_entity_unload_asleep_node( - hass: HomeAssistant, - client: MagicMock, - wallmote_central_scene: Node, - integration: MockConfigEntry, -) -> None: - """Test unloading config entry after attempting an update for an asleep node.""" - config_entry = integration - assert client.async_send_command.call_count == 0 - - client.async_send_command.reset_mock() - client.async_send_command.return_value = {"updates": []} - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) - await hass.async_block_till_done() - - # Once call completed for the (awake) controller node. - assert client.async_send_command.call_count == 1 - assert len(wallmote_central_scene._listeners["wake up"]) == 1 - - await hass.config_entries.async_unload(config_entry.entry_id) - assert client.async_send_command.call_count == 1 - assert len(wallmote_central_scene._listeners["wake up"]) == 0 diff --git a/tests/helpers/test_automation.py b/tests/helpers/test_automation.py new file mode 100644 index 00000000000..1cd9944aecf --- /dev/null +++ b/tests/helpers/test_automation.py @@ -0,0 +1,36 @@ +"""Test automation helpers.""" + +import pytest + +from homeassistant.helpers.automation import ( + get_absolute_description_key, + get_relative_description_key, +) + + +@pytest.mark.parametrize( + ("relative_key", "absolute_key"), + [ + ("turned_on", "homeassistant.turned_on"), + ("_", "homeassistant"), + ("_state", "state"), + ], +) +def test_absolute_description_key(relative_key: str, absolute_key: str) -> None: + """Test absolute description key.""" + DOMAIN = "homeassistant" + assert get_absolute_description_key(DOMAIN, relative_key) == absolute_key + + +@pytest.mark.parametrize( + ("relative_key", "absolute_key"), + [ + ("turned_on", "homeassistant.turned_on"), + ("_", "homeassistant"), + ("_state", "state"), + ], +) +def test_relative_description_key(relative_key: str, absolute_key: str) -> None: + """Test relative description key.""" + DOMAIN = "homeassistant" + assert get_relative_description_key(DOMAIN, absolute_key) == relative_key diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 86aab3cb681..b037d6a450e 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2073,7 +2073,7 @@ async def test_platform_async_get_conditions(hass: HomeAssistant) -> None: config = {CONF_DEVICE_ID: "test", CONF_DOMAIN: "test", CONF_CONDITION: "device"} with patch( "homeassistant.components.device_automation.condition.async_get_conditions", - AsyncMock(return_value={"device": AsyncMock()}), + AsyncMock(return_value={"_device": AsyncMock()}), ) as device_automation_async_get_conditions_mock: await condition.async_validate_condition_config(hass, config) device_automation_async_get_conditions_mock.assert_awaited() @@ -2089,7 +2089,7 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: """Initialize condition.""" @classmethod - async def async_validate_condition_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" @@ -2098,14 +2098,14 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: class MockCondition1(MockCondition): """Mock condition 1.""" - async def async_condition_from_config(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" return lambda hass, vars: True class MockCondition2(MockCondition): """Mock condition 2.""" - async def async_condition_from_config(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" return lambda hass, vars: False @@ -2113,8 +2113,8 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: hass: HomeAssistant, ) -> dict[str, type[condition.Condition]]: return { - "test": MockCondition1, - "test.cond_2": MockCondition2, + "_": MockCondition1, + "cond_2": MockCondition2, } mock_integration(hass, MockModule("test")) @@ -2337,7 +2337,7 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None "sun_condition_descriptions", [ """ - sun: + _: fields: after: example: sunrise @@ -2371,7 +2371,7 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None .offset_selector: &offset_selector selector: time: null - sun: + _: fields: after: *sunrise_sunset_selector after_offset: *offset_selector @@ -2385,7 +2385,7 @@ async def test_async_get_all_descriptions( ) -> None: """Test async_get_all_descriptions.""" device_automation_condition_descriptions = """ - device: {} + _device: {} """ assert await async_setup_component(hass, DOMAIN_SUN, {}) @@ -2415,7 +2415,7 @@ async def test_async_get_all_descriptions( # Test we only load conditions.yaml for integrations with conditions, # system_health has no conditions - assert proxy_load_conditions_files.mock_calls[0][1][1] == unordered( + assert proxy_load_conditions_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, DOMAIN_SUN), ] @@ -2423,7 +2423,7 @@ async def test_async_get_all_descriptions( # system_health does not have conditions and should not be in descriptions assert descriptions == { - DOMAIN_SUN: { + "sun": { "fields": { "after": { "example": "sunrise", @@ -2459,7 +2459,7 @@ async def test_async_get_all_descriptions( "device": { "fields": {}, }, - DOMAIN_SUN: { + "sun": { "fields": { "after": { "example": "sunrise", @@ -2525,7 +2525,7 @@ async def test_async_get_all_descriptions_with_bad_description( ) -> None: """Test async_get_all_descriptions.""" sun_service_descriptions = """ - sun: + _: fields: not_a_dict """ @@ -2545,11 +2545,11 @@ async def test_async_get_all_descriptions_with_bad_description( ): descriptions = await condition.async_get_all_descriptions(hass) - assert descriptions == {DOMAIN_SUN: None} + assert descriptions == {"sun": None} assert ( "Unable to parse conditions.yaml for the sun integration: " - "expected a dictionary for dictionary value @ data['sun']['fields']" + "expected a dictionary for dictionary value @ data['_']['fields']" ) in caplog.text diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index fa31ef375fd..09fb16cbe9a 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -36,6 +36,29 @@ from tests.common import ( ) +async def set_states_and_check_target_events( + hass: HomeAssistant, + events: list[target.TargetStateChangedData], + state: str, + entities_to_set_state: list[str], + entities_to_assert_change: list[str], +) -> None: + """Toggle the state entities and check for events.""" + for entity_id in entities_to_set_state: + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + + assert len(events) == len(entities_to_assert_change) + entities_seen = set() + for event in events: + state_change_event = event.state_change_event + entities_seen.add(state_change_event.data["entity_id"]) + assert state_change_event.data["new_state"].state == state + assert event.targeted_entity_ids == set(entities_to_assert_change) + assert entities_seen == set(entities_to_assert_change) + events.clear() + + @pytest.fixture def registries_mock(hass: HomeAssistant) -> None: """Mock including floor and area info.""" @@ -497,19 +520,9 @@ async def test_async_track_target_selector_state_change_event( """Toggle the state entities and check for events.""" nonlocal last_state last_state = STATE_ON if last_state == STATE_OFF else STATE_OFF - for entity_id in entities_to_set_state: - hass.states.async_set(entity_id, last_state) - await hass.async_block_till_done() - - assert len(events) == len(entities_to_assert_change) - entities_seen = set() - for event in events: - state_change_event = event.state_change_event - entities_seen.add(state_change_event.data["entity_id"]) - assert state_change_event.data["new_state"].state == last_state - assert event.targeted_entity_ids == set(entities_to_assert_change) - assert entities_seen == set(entities_to_assert_change) - events.clear() + await set_states_and_check_target_events( + hass, events, last_state, entities_to_set_state, entities_to_assert_change + ) config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) @@ -645,3 +658,91 @@ async def test_async_track_target_selector_state_change_event( # After unsubscribing, changes should not trigger unsub() await set_states_and_check_events(targeted_entities, []) + + +async def test_async_track_target_selector_state_change_event_filter( + hass: HomeAssistant, +) -> None: + """Test async_track_target_selector_state_change_event with entity filter.""" + events: list[target.TargetStateChangedData] = [] + + filtered_entity = "" + + @callback + def entity_filter(entity_ids: set[str]) -> set[str]: + return {entity_id for entity_id in entity_ids if entity_id != filtered_entity} + + @callback + def state_change_callback(event: target.TargetStateChangedData): + """Handle state change events.""" + events.append(event) + + last_state = STATE_OFF + + async def set_states_and_check_events( + entities_to_set_state: list[str], entities_to_assert_change: list[str] + ) -> None: + """Toggle the state entities and check for events.""" + nonlocal last_state + last_state = STATE_ON if last_state == STATE_OFF else STATE_OFF + await set_states_and_check_target_events( + hass, events, last_state, entities_to_set_state, entities_to_assert_change + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + entity_reg = er.async_get(hass) + + label = lr.async_get(hass).async_create("Test Label").name + label_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="label_light", + ).entity_id + entity_reg.async_update_entity(label_entity, labels={label}) + + targeted_entity = "light.test_light" + + targeted_entities = [targeted_entity, label_entity] + await set_states_and_check_events(targeted_entities, []) + + selector_config = { + ATTR_ENTITY_ID: targeted_entity, + ATTR_LABEL_ID: label, + } + unsub = target.async_track_target_selector_state_change_event( + hass, selector_config, state_change_callback, entity_filter + ) + + await set_states_and_check_events( + targeted_entities, [targeted_entity, label_entity] + ) + + filtered_entity = targeted_entity + # Fire an event so that the targeted entities are re-evaluated + hass.bus.async_fire( + er.EVENT_ENTITY_REGISTRY_UPDATED, + { + "action": "update", + "entity_id": "light.other", + "changes": {}, + }, + ) + await set_states_and_check_events([targeted_entity, label_entity], [label_entity]) + + filtered_entity = label_entity + # Fire an event so that the targeted entities are re-evaluated + hass.bus.async_fire( + er.EVENT_ENTITY_REGISTRY_UPDATED, + { + "action": "update", + "entity_id": "light.other", + "changes": {}, + }, + ) + await set_states_and_check_events( + [targeted_entity, label_entity], [targeted_entity] + ) + + unsub() diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 82b6434cf3f..85a2673f17d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -845,6 +845,23 @@ def test_as_function(hass: HomeAssistant) -> None: ) +def test_as_function_no_arguments(hass: HomeAssistant) -> None: + """Test as_function with no arguments.""" + assert ( + template.Template( + """ + {%- macro macro_get_hello(returns) -%} + {%- do returns("Hello") -%} + {%- endmacro -%} + {%- set get_hello = macro_get_hello | as_function -%} + {{ get_hello() }} + """, + hass, + ).async_render() + == "Hello" + ) + + def test_logarithm(hass: HomeAssistant) -> None: """Test logarithm.""" tests = [ diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 050420d0195..d5621a1ae61 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -50,7 +50,7 @@ async def test_trigger_subtype(hass: HomeAssistant) -> None: "homeassistant.helpers.trigger.async_get_integration", return_value=MagicMock(async_get_platform=AsyncMock()), ) as integration_mock: - await _async_get_trigger_platform(hass, {"platform": "test.subtype"}) + await _async_get_trigger_platform(hass, "test.subtype") assert integration_mock.call_args == call(hass, "test") @@ -461,7 +461,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: """Initialize trigger.""" @classmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" @@ -470,7 +470,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: class MockTrigger1(MockTrigger): """Mock trigger 1.""" - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, @@ -481,7 +481,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: class MockTrigger2(MockTrigger): """Mock trigger 2.""" - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, @@ -493,8 +493,8 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: hass: HomeAssistant, ) -> dict[str, type[Trigger]]: return { - "test": MockTrigger1, - "test.trig_2": MockTrigger2, + "_": MockTrigger1, + "trig_2": MockTrigger2, } mock_integration(hass, MockModule("test")) @@ -534,7 +534,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: "sun_trigger_descriptions", [ """ - sun: + _: fields: event: example: sunrise @@ -551,7 +551,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: .anchor: &anchor - sunrise - sunset - sun: + _: fields: event: example: sunrise @@ -569,7 +569,7 @@ async def test_async_get_all_descriptions( ) -> None: """Test async_get_all_descriptions.""" tag_trigger_descriptions = """ - tag: + _: fields: entity: selector: @@ -607,7 +607,7 @@ async def test_async_get_all_descriptions( # Test we only load triggers.yaml for integrations with triggers, # system_health has no triggers - assert proxy_load_triggers_files.mock_calls[0][1][1] == unordered( + assert proxy_load_triggers_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, DOMAIN_SUN), ] @@ -615,7 +615,7 @@ async def test_async_get_all_descriptions( # system_health does not have triggers and should not be in descriptions assert descriptions == { - DOMAIN_SUN: { + "sun": { "fields": { "event": { "example": "sunrise", @@ -650,7 +650,7 @@ async def test_async_get_all_descriptions( new_descriptions = await trigger.async_get_all_descriptions(hass) assert new_descriptions is not descriptions assert new_descriptions == { - DOMAIN_SUN: { + "sun": { "fields": { "event": { "example": "sunrise", @@ -666,7 +666,7 @@ async def test_async_get_all_descriptions( "offset": {"selector": {"time": {}}}, } }, - DOMAIN_TAG: { + "tag": { "fields": { "entity": { "selector": { @@ -736,7 +736,7 @@ async def test_async_get_all_descriptions_with_bad_description( ) -> None: """Test async_get_all_descriptions.""" sun_service_descriptions = """ - sun: + _: fields: not_a_dict """ @@ -760,7 +760,7 @@ async def test_async_get_all_descriptions_with_bad_description( assert ( "Unable to parse triggers.yaml for the sun integration: " - "expected a dictionary for dictionary value @ data['sun']['fields']" + "expected a dictionary for dictionary value @ data['_']['fields']" ) in caplog.text @@ -787,7 +787,7 @@ async def test_subscribe_triggers( ) -> None: """Test trigger.async_subscribe_platform_events.""" sun_trigger_descriptions = """ - sun: {} + _: {} """ def _load_yaml(fname, secrets=None): diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index b4216a3fc6d..57e80927e7e 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -942,17 +942,24 @@ async def test_config_entry_custom_integration( # Default without context should be None crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is None - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + # Should not log any warnings about ContextVar usage for custom integrations + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 # Explicit None is OK caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=None ) + assert crd.config_entry is None assert ( "Detected that integration 'my_integration' relies on ContextVar" @@ -961,38 +968,53 @@ async def test_config_entry_custom_integration( # Explicit entry is OK caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=entry ) + assert crd.config_entry is entry - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 # set ContextVar config_entries.current_entry.set(entry) # Default with ContextVar should match the ContextVar caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is entry - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 # Explicit entry different from ContextVar not recommended, but should work another_entry = MockConfigEntry() caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=another_entry ) + assert crd.config_entry is another_entry - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> None: