diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 30c69d01dc1b2..b74a30a40f892 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -27,7 +27,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 @@ -94,7 +94,7 @@ jobs: - arch: i386 steps: - name: Checkout the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -227,7 +227,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set build additional args run: | @@ -265,7 +265,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -309,7 +309,7 @@ jobs: registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] steps: - name: Checkout the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Install Cosign uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 @@ -418,7 +418,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 @@ -463,7 +463,7 @@ jobs: HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }} steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Login to GitHub Container Registry uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7c3eb49efb335..a3651ff56ff75 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -99,7 +99,7 @@ jobs: steps: - &checkout name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Generate partial Python venv restore key id: generate_python_cache_key run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0ab94aa4e8993..7bce2ab5c0189 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Initialize CodeQL uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index fb4cb43e7c043..17f8b3c90f20e 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b90fe05efc448..6b2a3c95db827 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -33,7 +33,7 @@ jobs: steps: - &checkout name: Checkout the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python diff --git a/homeassistant/components/cosori/__init__.py b/homeassistant/components/cosori/__init__.py new file mode 100644 index 0000000000000..8e0d4d8cd08ed --- /dev/null +++ b/homeassistant/components/cosori/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Cosori.""" diff --git a/homeassistant/components/cosori/manifest.json b/homeassistant/components/cosori/manifest.json new file mode 100644 index 0000000000000..3bdc67e830327 --- /dev/null +++ b/homeassistant/components/cosori/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "cosori", + "name": "Cosori", + "integration_type": "virtual", + "supported_by": "vesync" +} diff --git a/homeassistant/components/cync/__init__.py b/homeassistant/components/cync/__init__.py index a2fa7ad509a8d..ba340f90fd772 100644 --- a/homeassistant/components/cync/__init__.py +++ b/homeassistant/components/cync/__init__.py @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.ssl import get_default_context from .const import ( CONF_AUTHORIZE_STRING, @@ -31,9 +32,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool expires_at=entry.data[CONF_EXPIRES_AT], ) cync_auth = Auth(async_get_clientsession(hass), user=user_info) + ssl_context = get_default_context() try: - cync = await Cync.create(cync_auth) + cync = await Cync.create( + auth=cync_auth, + ssl_context=ssl_context, + ) except AuthFailedError as ex: raise ConfigEntryAuthFailed("User token invalid") from ex except CyncError as ex: diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 18598dd4c94cd..380c207dbb2e8 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -112,6 +112,7 @@ async def async_update_data(): update_method=async_update_data, # Polling interval. Will only be polled if there are subscribers. update_interval=timedelta(hours=1), + config_entry=entry, ) # Fetch initial data so we have data when entities subscribe diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index aab9ad7ca88e7..d856a5f7eaf84 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -2,6 +2,8 @@ from collections.abc import Callable +from pypck.device import DeviceConnection + from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -10,7 +12,6 @@ from .const import CONF_DOMAIN_DATA, DOMAIN from .helpers import ( AddressType, - DeviceConnectionType, InputType, LcnConfigEntry, generate_unique_id, @@ -23,7 +24,7 @@ class LcnEntity(Entity): """Parent class for all entities associated with the LCN component.""" _attr_has_entity_name = True - device_connection: DeviceConnectionType + device_connection: DeviceConnection def __init__( self, diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index feeaec6268a73..5a55d7c56c511 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -11,6 +11,7 @@ import pypck from pypck.connection import PchkConnectionManager +from pypck.device import DeviceConnection from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -48,7 +49,7 @@ class LcnRuntimeData: connection: PchkConnectionManager """Connection to PCHK host.""" - device_connections: dict[str, DeviceConnectionType] + device_connections: dict[str, DeviceConnection] """Logical addresses of devices connected to the host.""" add_entities_callbacks: dict[str, Callable[[Iterable[ConfigType]], None]] @@ -59,7 +60,6 @@ class LcnRuntimeData: type LcnConfigEntry = ConfigEntry[LcnRuntimeData] type AddressType = tuple[int, int, bool] -type DeviceConnectionType = pypck.module.ModuleConnection | pypck.module.GroupConnection type InputType = type[pypck.inputs.Input] @@ -82,11 +82,11 @@ class LcnRuntimeData: def get_device_connection( hass: HomeAssistant, address: AddressType, config_entry: LcnConfigEntry -) -> DeviceConnectionType: +) -> DeviceConnection: """Return a lcn device_connection.""" host_connection = config_entry.runtime_data.connection addr = pypck.lcn_addr.LcnAddr(*address) - return host_connection.get_address_conn(addr) + return host_connection.get_device_connection(addr) def get_resource(domain_name: str, domain_data: ConfigType) -> str: @@ -246,7 +246,7 @@ def register_lcn_address_devices( async def async_update_device_config( - device_connection: DeviceConnectionType, device_config: ConfigType + device_connection: DeviceConnection, device_config: ConfigType ) -> None: """Fill missing values in device_config with infos from LCN bus.""" # fetch serial info if device is module diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 8c5da184b5228..8b442d2aad3ea 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["pypck"], "quality_scale": "bronze", - "requirements": ["pypck==0.9.2", "lcn-frontend==0.2.7"] + "requirements": ["pypck==0.9.3", "lcn-frontend==0.2.7"] } diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 738397e8cb5a5..4672e244649b5 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -3,6 +3,7 @@ from enum import StrEnum, auto import pypck +from pypck.device import DeviceConnection import voluptuous as vol from homeassistant.const import ( @@ -48,7 +49,7 @@ VAR_UNITS, VARIABLES, ) -from .helpers import DeviceConnectionType, LcnConfigEntry, is_states_string +from .helpers import LcnConfigEntry, is_states_string class LcnServiceCall: @@ -65,7 +66,7 @@ def __init__(self, hass: HomeAssistant) -> None: """Initialize service call.""" self.hass = hass - def get_device_connection(self, service: ServiceCall) -> DeviceConnectionType: + def get_device_connection(self, service: ServiceCall) -> DeviceConnection: """Get address connection object.""" entries: list[LcnConfigEntry] = self.hass.config_entries.async_loaded_entries( DOMAIN diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 87399afc295bb..25f56ba2dfeab 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -7,6 +7,7 @@ from typing import Any, Final import lcn_frontend as lcn_panel +from pypck.device import DeviceConnection import voluptuous as vol from homeassistant.components import panel_custom, websocket_api @@ -37,7 +38,6 @@ DOMAIN, ) from .helpers import ( - DeviceConnectionType, LcnConfigEntry, async_update_device_config, generate_unique_id, @@ -182,7 +182,7 @@ async def websocket_scan_devices( host_connection = config_entry.runtime_data.connection await host_connection.scan_modules() - for device_connection in host_connection.address_conns.values(): + for device_connection in host_connection.device_connections.values(): if not device_connection.is_group: await async_create_or_update_device_in_config_entry( hass, device_connection, config_entry @@ -421,7 +421,7 @@ async def websocket_delete_entity( async def async_create_or_update_device_in_config_entry( hass: HomeAssistant, - device_connection: DeviceConnectionType, + device_connection: DeviceConnection, config_entry: LcnConfigEntry, ) -> None: """Create or update device in config_entry according to given device_connection.""" diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 9fadca31b5061..64b367112efd9 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -41,9 +41,11 @@ DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, + DATA_PENDING_UPDATES, DATA_PUSH_CHANNEL, DATA_STORE, DOMAIN, + SENSOR_TYPES, STORAGE_KEY, STORAGE_VERSION, ) @@ -75,6 +77,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DATA_DEVICES: {}, DATA_PUSH_CHANNEL: {}, DATA_STORE: store, + DATA_PENDING_UPDATES: {sensor_type: {} for sensor_type in SENSOR_TYPES}, } hass.http.register_view(RegistrationsView()) diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index c8638aa37a316..5a203ee479832 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -4,7 +4,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON +from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -75,8 +75,9 @@ class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): async def async_restore_last_state(self, last_state: State) -> None: """Restore previous state.""" - await super().async_restore_last_state(last_state) - self._config[ATTR_SENSOR_STATE] = last_state.state == STATE_ON + if self._config[ATTR_SENSOR_STATE] in (None, STATE_UNKNOWN): + await super().async_restore_last_state(last_state) + self._config[ATTR_SENSOR_STATE] = last_state.state == STATE_ON self._async_update_attr_from_config() @callback diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 1dab894b2f657..a4ed3ea598bd4 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -20,6 +20,7 @@ DATA_STORE = "store" DATA_NOTIFY = "notify" DATA_PUSH_CHANNEL = "push_channel" +DATA_PENDING_UPDATES = "pending_updates" ATTR_APP_DATA = "app_data" ATTR_APP_ID = "app_id" @@ -94,3 +95,5 @@ }, extra=vol.ALLOW_EXTRA, ) + +SENSOR_TYPES = (ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR) diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index a0ad4c4596382..89b207e29ead2 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -2,10 +2,16 @@ from __future__ import annotations -from typing import Any +import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ICON, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity @@ -18,10 +24,15 @@ ATTR_SENSOR_ICON, ATTR_SENSOR_STATE, ATTR_SENSOR_STATE_CLASS, + ATTR_SENSOR_TYPE, + DATA_PENDING_UPDATES, + DOMAIN, SIGNAL_SENSOR_UPDATE, ) from .helpers import device_info +_LOGGER = logging.getLogger(__name__) + class MobileAppEntity(RestoreEntity): """Representation of a mobile app entity.""" @@ -56,11 +67,14 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( async_dispatcher_connect( self.hass, - f"{SIGNAL_SENSOR_UPDATE}-{self._attr_unique_id}", + f"{SIGNAL_SENSOR_UPDATE}-{self._config[ATTR_SENSOR_TYPE]}-{self._attr_unique_id}", self._handle_update, ) ) + # Apply any pending updates + self._handle_update() + if (state := await self.async_get_last_state()) is None: return @@ -69,13 +83,16 @@ async def async_added_to_hass(self) -> None: async def async_restore_last_state(self, last_state: State) -> None: """Restore previous state.""" config = self._config - config[ATTR_SENSOR_STATE] = last_state.state - config[ATTR_SENSOR_ATTRIBUTES] = { - **last_state.attributes, - **self._config[ATTR_SENSOR_ATTRIBUTES], - } - if ATTR_ICON in last_state.attributes: - config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON] + + # Only restore state if we don't have one already, since it can be set by a pending update + if config[ATTR_SENSOR_STATE] in (None, STATE_UNKNOWN): + config[ATTR_SENSOR_STATE] = last_state.state + config[ATTR_SENSOR_ATTRIBUTES] = { + **last_state.attributes, + **self._config[ATTR_SENSOR_ATTRIBUTES], + } + if ATTR_ICON in last_state.attributes: + config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON] @property def device_info(self): @@ -83,8 +100,21 @@ def device_info(self): return device_info(self._registration) @callback - def _handle_update(self, data: dict[str, Any]) -> None: + def _handle_update(self) -> None: """Handle async event updates.""" - self._config.update(data) + self._apply_pending_update() self._async_update_attr_from_config() self.async_write_ha_state() + + def _apply_pending_update(self) -> None: + """Restore any pending update for this entity.""" + entity_type = self._config[ATTR_SENSOR_TYPE] + pending_updates = self.hass.data[DOMAIN][DATA_PENDING_UPDATES][entity_type] + if update := pending_updates.pop(self._attr_unique_id, None): + _LOGGER.debug( + "Applying pending update for %s: %s", + self._attr_unique_id, + update, + ) + # Apply the pending update + self._config.update(update) diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 6a2c55d2fd79c..65770b99aad68 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -86,24 +86,26 @@ class MobileAppSensor(MobileAppEntity, RestoreSensor): async def async_restore_last_state(self, last_state: State) -> None: """Restore previous state.""" - await super().async_restore_last_state(last_state) config = self._config - if not (last_sensor_data := await self.async_get_last_sensor_data()): - # Workaround to handle migration to RestoreSensor, can be removed - # in HA Core 2023.4 - config[ATTR_SENSOR_STATE] = None - webhook_id = self._entry.data[CONF_WEBHOOK_ID] - if TYPE_CHECKING: - assert self.unique_id is not None - sensor_unique_id = _extract_sensor_unique_id(webhook_id, self.unique_id) - if ( - self.device_class == SensorDeviceClass.TEMPERATURE - and sensor_unique_id == "battery_temperature" - ): - config[ATTR_SENSOR_UOM] = UnitOfTemperature.CELSIUS - else: - config[ATTR_SENSOR_STATE] = last_sensor_data.native_value - config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement + if config[ATTR_SENSOR_STATE] in (None, STATE_UNKNOWN): + await super().async_restore_last_state(last_state) + + if not (last_sensor_data := await self.async_get_last_sensor_data()): + # Workaround to handle migration to RestoreSensor, can be removed + # in HA Core 2023.4 + config[ATTR_SENSOR_STATE] = None + webhook_id = self._entry.data[CONF_WEBHOOK_ID] + if TYPE_CHECKING: + assert self.unique_id is not None + sensor_unique_id = _extract_sensor_unique_id(webhook_id, self.unique_id) + if ( + self.device_class == SensorDeviceClass.TEMPERATURE + and sensor_unique_id == "battery_temperature" + ): + config[ATTR_SENSOR_UOM] = UnitOfTemperature.CELSIUS + else: + config[ATTR_SENSOR_STATE] = last_sensor_data.native_value + config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement self._async_update_attr_from_config() diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index c852409b3b776..102182026683d 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -79,7 +79,6 @@ ATTR_SENSOR_STATE, ATTR_SENSOR_STATE_CLASS, ATTR_SENSOR_TYPE, - ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR, ATTR_SENSOR_UNIQUE_ID, ATTR_SENSOR_UOM, @@ -98,12 +97,14 @@ DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, + DATA_PENDING_UPDATES, DOMAIN, ERR_ENCRYPTION_ALREADY_ENABLED, ERR_ENCRYPTION_REQUIRED, ERR_INVALID_FORMAT, ERR_SENSOR_NOT_REGISTERED, SCHEMA_APP_DATA, + SENSOR_TYPES, SIGNAL_LOCATION_UPDATE, SIGNAL_SENSOR_UPDATE, ) @@ -125,8 +126,6 @@ str, Callable[[HomeAssistant, ConfigEntry, Any], Coroutine[Any, Any, Response]] ] = Registry() -SENSOR_TYPES = (ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR) - WEBHOOK_PAYLOAD_SCHEMA = vol.Any( vol.Schema( { @@ -601,14 +600,16 @@ async def webhook_register_sensor( if changes: entity_registry.async_update_entity(existing_sensor, **changes) - async_dispatcher_send(hass, f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", data) + _async_update_sensor_entity( + hass, entity_type=entity_type, unique_store_key=unique_store_key, data=data + ) else: data[CONF_UNIQUE_ID] = unique_store_key data[CONF_NAME] = ( f"{config_entry.data[ATTR_DEVICE_NAME]} {data[ATTR_SENSOR_NAME]}" ) - register_signal = f"{DOMAIN}_{data[ATTR_SENSOR_TYPE]}_register" + register_signal = f"{DOMAIN}_{entity_type}_register" async_dispatcher_send(hass, register_signal, data) return webhook_response( @@ -685,10 +686,12 @@ async def webhook_update_sensor_states( continue sensor[CONF_WEBHOOK_ID] = config_entry.data[CONF_WEBHOOK_ID] - async_dispatcher_send( + + _async_update_sensor_entity( hass, - f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", - sensor, + entity_type=entity_type, + unique_store_key=unique_store_key, + data=sensor, ) resp[unique_id] = {"success": True} @@ -697,11 +700,26 @@ async def webhook_update_sensor_states( entry = entity_registry.async_get(entity_id) if entry and entry.disabled_by: + # Inform the app that the entity is disabled resp[unique_id]["is_disabled"] = True return webhook_response(resp, registration=config_entry.data) +def _async_update_sensor_entity( + hass: HomeAssistant, entity_type: str, unique_store_key: str, data: dict[str, Any] +) -> None: + """Update a sensor entity with new data.""" + # Replace existing pending update with the latest sensor data. + hass.data[DOMAIN][DATA_PENDING_UPDATES][entity_type][unique_store_key] = data + + # The signal might not be handled if the entity was just enabled, but the data is stored + # in pending updates and will be applied on entity initialization. + async_dispatcher_send( + hass, f"{SIGNAL_SENSOR_UPDATE}-{entity_type}-{unique_store_key}" + ) + + @WEBHOOK_COMMANDS.register("get_zones") async def webhook_get_zones( hass: HomeAssistant, config_entry: ConfigEntry, data: Any diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 68a689d3d9a0c..1077175ab7a24 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -27,7 +27,8 @@ ) from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER -from .services import get_music_assistant_client, register_actions +from .helpers import get_music_assistant_client +from .services import register_actions if TYPE_CHECKING: from music_assistant_models.event import MassEvent diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py index b228e99f76f3c..2f8512dc7c65c 100644 --- a/homeassistant/components/music_assistant/helpers.py +++ b/homeassistant/components/music_assistant/helpers.py @@ -4,11 +4,18 @@ from collections.abc import Callable, Coroutine import functools -from typing import Any +from typing import TYPE_CHECKING, Any from music_assistant_models.errors import MusicAssistantError -from homeassistant.exceptions import HomeAssistantError +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +if TYPE_CHECKING: + from music_assistant_client import MusicAssistantClient + + from . import MusicAssistantConfigEntry def catch_musicassistant_error[**_P, _R]( @@ -26,3 +33,16 @@ async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: raise HomeAssistantError(error_msg) from err return wrapper + + +@callback +def get_music_assistant_client( + hass: HomeAssistant, config_entry_id: str +) -> MusicAssistantClient: + """Get the Music Assistant client for the given config entry.""" + entry: MusicAssistantConfigEntry | None + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError("Entry not found") + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError("Entry not loaded") + return entry.runtime_data.mass diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 0eaea6b4e9112..b8faac270eca3 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -22,11 +22,9 @@ from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType, Track from music_assistant_models.player_queue import PlayerQueue -import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( - ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_EXTRA, BrowseMedia, MediaPlayerDeviceClass, @@ -41,38 +39,26 @@ async_process_play_media_url, ) from homeassistant.const import ATTR_NAME, STATE_OFF, Platform -from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse +from homeassistant.core import HomeAssistant, ServiceResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - async_get_current_platform, -) +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utc_from_timestamp from . import MusicAssistantConfigEntry from .const import ( ATTR_ACTIVE, ATTR_ACTIVE_QUEUE, - ATTR_ALBUM, - ATTR_ANNOUNCE_VOLUME, - ATTR_ARTIST, - ATTR_AUTO_PLAY, ATTR_CURRENT_INDEX, ATTR_CURRENT_ITEM, ATTR_ELAPSED_TIME, ATTR_ITEMS, ATTR_MASS_PLAYER_TYPE, - ATTR_MEDIA_ID, - ATTR_MEDIA_TYPE, ATTR_NEXT_ITEM, ATTR_QUEUE_ID, ATTR_RADIO_MODE, ATTR_REPEAT_MODE, ATTR_SHUFFLE_ENABLED, - ATTR_SOURCE_PLAYER, - ATTR_URL, - ATTR_USE_PRE_ANNOUNCE, DOMAIN, ) from .entity import MusicAssistantEntity @@ -122,11 +108,6 @@ # UNKNOWN is intentionally not mapped - will return None } -SERVICE_PLAY_MEDIA_ADVANCED = "play_media" -SERVICE_PLAY_ANNOUNCEMENT = "play_announcement" -SERVICE_TRANSFER_QUEUE = "transfer_queue" -SERVICE_GET_QUEUE = "get_queue" - async def async_setup_entry( hass: HomeAssistant, @@ -143,44 +124,6 @@ def add_player(player_id: str) -> None: # register callback to add players when they are discovered entry.runtime_data.platform_handlers.setdefault(Platform.MEDIA_PLAYER, add_player) - # add platform service for play_media with advanced options - platform = async_get_current_platform() - platform.async_register_entity_service( - SERVICE_PLAY_MEDIA_ADVANCED, - { - vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), - vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption), - vol.Optional(ATTR_ARTIST): cv.string, - vol.Optional(ATTR_ALBUM): cv.string, - vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool), - }, - "_async_handle_play_media", - ) - platform.async_register_entity_service( - SERVICE_PLAY_ANNOUNCEMENT, - { - vol.Required(ATTR_URL): cv.string, - vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool), - vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int), - }, - "_async_handle_play_announcement", - ) - platform.async_register_entity_service( - SERVICE_TRANSFER_QUEUE, - { - vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id, - vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool), - }, - "_async_handle_transfer_queue", - ) - platform.async_register_entity_service( - SERVICE_GET_QUEUE, - schema=None, - func="_async_handle_get_queue", - supports_response=SupportsResponse.ONLY, - ) - class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Representation of MediaPlayerEntity from Music Assistant Player.""" diff --git a/homeassistant/components/music_assistant/services.py b/homeassistant/components/music_assistant/services.py index a0e82ba331521..25ccfd3c0f9e7 100644 --- a/homeassistant/components/music_assistant/services.py +++ b/homeassistant/components/music_assistant/services.py @@ -4,10 +4,13 @@ from typing import TYPE_CHECKING -from music_assistant_models.enums import MediaType +from music_assistant_models.enums import MediaType, QueueOption import voluptuous as vol -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.media_player import ( + ATTR_MEDIA_ENQUEUE, + DOMAIN as MEDIA_PLAYER_DOMAIN, +) from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, @@ -17,31 +20,41 @@ callback, ) from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, service from .const import ( + ATTR_ALBUM, ATTR_ALBUM_ARTISTS_ONLY, ATTR_ALBUM_TYPE, ATTR_ALBUMS, + ATTR_ANNOUNCE_VOLUME, + ATTR_ARTIST, ATTR_ARTISTS, ATTR_AUDIOBOOKS, + ATTR_AUTO_PLAY, ATTR_FAVORITE, ATTR_ITEMS, ATTR_LIBRARY_ONLY, ATTR_LIMIT, + ATTR_MEDIA_ID, ATTR_MEDIA_TYPE, ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, ATTR_PODCASTS, ATTR_RADIO, + ATTR_RADIO_MODE, ATTR_SEARCH, ATTR_SEARCH_ALBUM, ATTR_SEARCH_ARTIST, ATTR_SEARCH_NAME, + ATTR_SOURCE_PLAYER, ATTR_TRACKS, + ATTR_URL, + ATTR_USE_PRE_ANNOUNCE, DOMAIN, ) +from .helpers import get_music_assistant_client from .schemas import ( LIBRARY_RESULTS_SCHEMA, SEARCH_RESULT_SCHEMA, @@ -49,7 +62,6 @@ ) if TYPE_CHECKING: - from music_assistant_client import MusicAssistantClient from music_assistant_models.media_items import ( Album, Artist, @@ -60,28 +72,18 @@ Track, ) - from . import MusicAssistantConfigEntry - SERVICE_SEARCH = "search" SERVICE_GET_LIBRARY = "get_library" +SERVICE_PLAY_MEDIA_ADVANCED = "play_media" +SERVICE_PLAY_ANNOUNCEMENT = "play_announcement" +SERVICE_TRANSFER_QUEUE = "transfer_queue" +SERVICE_GET_QUEUE = "get_queue" + DEFAULT_OFFSET = 0 DEFAULT_LIMIT = 25 DEFAULT_SORT_ORDER = "name" -@callback -def get_music_assistant_client( - hass: HomeAssistant, config_entry_id: str -) -> MusicAssistantClient: - """Get the Music Assistant client for the given config entry.""" - entry: MusicAssistantConfigEntry | None - if not (entry := hass.config_entries.async_get_entry(config_entry_id)): - raise ServiceValidationError("Entry not found") - if entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError("Entry not loaded") - return entry.runtime_data.mass - - @callback def register_actions(hass: HomeAssistant) -> None: """Register custom actions.""" @@ -124,6 +126,55 @@ def register_actions(hass: HomeAssistant) -> None: supports_response=SupportsResponse.ONLY, ) + # Platform entity services + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={ + vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), + vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption), + vol.Optional(ATTR_ARTIST): cv.string, + vol.Optional(ATTR_ALBUM): cv.string, + vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool), + }, + func="_async_handle_play_media", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_PLAY_ANNOUNCEMENT, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={ + vol.Required(ATTR_URL): cv.string, + vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool), + vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int), + }, + func="_async_handle_play_announcement", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_TRANSFER_QUEUE, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={ + vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id, + vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool), + }, + func="_async_handle_transfer_queue", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_QUEUE, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema=None, + func="_async_handle_get_queue", + supports_response=SupportsResponse.ONLY, + ) + async def handle_search(call: ServiceCall) -> ServiceResponse: """Handle queue_command action.""" diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 9a1ace9994aed..baa954cdbdac9 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -6,6 +6,7 @@ import logging from typing import Any +from ns_api import Trip import voluptuous as vol from homeassistant.components.sensor import ( @@ -38,6 +39,33 @@ ) from .coordinator import NSConfigEntry, NSDataUpdateCoordinator + +def _get_departure_time(trip: Trip | None) -> datetime | None: + """Get next departure time from trip data.""" + return trip.departure_time_actual or trip.departure_time_planned if trip else None + + +def _get_time_str(time: datetime | None) -> str | None: + """Get time as string.""" + return time.strftime("%H:%M") if time else None + + +def _get_route(trip: Trip | None) -> list[str]: + """Get the route as a list of station names from trip data.""" + if not trip or not (trip_parts := trip.trip_parts): + return [] + route = [] + if departure := trip.departure: + route.append(departure) + route.extend(part.destination for part in trip_parts) + return route + + +def _get_delay(planned: datetime | None, actual: datetime | None) -> bool: + """Return True if delay is present, False otherwise.""" + return bool(planned and actual and planned != actual) + + _LOGGER = logging.getLogger(__name__) ROUTE_SCHEMA = vol.Schema( @@ -163,94 +191,38 @@ def native_value(self) -> datetime | None: return None first_trip = route_data.first_trip - if first_trip.departure_time_actual: - return first_trip.departure_time_actual - return first_trip.departure_time_planned + return _get_departure_time(first_trip) @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - route_data = self.coordinator.data - if not route_data: - return None - - first_trip = route_data.first_trip - next_trip = route_data.next_trip + first_trip = self.coordinator.data.first_trip + next_trip = self.coordinator.data.next_trip if not first_trip: return None - route = [] - if first_trip.trip_parts: - route = [first_trip.departure] - route.extend(k.destination for k in first_trip.trip_parts) - - # Static attributes - attributes = { + return { "going": first_trip.going, - "departure_time_planned": None, - "departure_time_actual": None, - "departure_delay": False, + "departure_time_planned": _get_time_str(first_trip.departure_time_planned), + "departure_time_actual": _get_time_str(first_trip.departure_time_actual), + "departure_delay": _get_delay( + first_trip.departure_time_planned, + first_trip.departure_time_actual, + ), "departure_platform_planned": first_trip.departure_platform_planned, "departure_platform_actual": first_trip.departure_platform_actual, - "arrival_time_planned": None, - "arrival_time_actual": None, - "arrival_delay": False, + "arrival_time_planned": _get_time_str(first_trip.arrival_time_planned), + "arrival_time_actual": _get_time_str(first_trip.arrival_time_actual), + "arrival_delay": _get_delay( + first_trip.arrival_time_planned, + first_trip.arrival_time_actual, + ), "arrival_platform_planned": first_trip.arrival_platform_planned, "arrival_platform_actual": first_trip.arrival_platform_actual, - "next": None, + "next": _get_time_str(_get_departure_time(next_trip)), "status": first_trip.status.lower() if first_trip.status else None, "transfers": first_trip.nr_transfers, - "route": route, + "route": _get_route(first_trip), "remarks": None, } - - # Planned departure attributes - if first_trip.departure_time_planned is not None: - attributes["departure_time_planned"] = ( - first_trip.departure_time_planned.strftime("%H:%M") - ) - - # Actual departure attributes - if first_trip.departure_time_actual is not None: - attributes["departure_time_actual"] = ( - first_trip.departure_time_actual.strftime("%H:%M") - ) - - # Delay departure attributes - if ( - attributes["departure_time_planned"] - and attributes["departure_time_actual"] - and attributes["departure_time_planned"] - != attributes["departure_time_actual"] - ): - attributes["departure_delay"] = True - - # Planned arrival attributes - if first_trip.arrival_time_planned is not None: - attributes["arrival_time_planned"] = ( - first_trip.arrival_time_planned.strftime("%H:%M") - ) - - # Actual arrival attributes - if first_trip.arrival_time_actual is not None: - attributes["arrival_time_actual"] = first_trip.arrival_time_actual.strftime( - "%H:%M" - ) - - # Delay arrival attributes - if ( - attributes["arrival_time_planned"] - and attributes["arrival_time_actual"] - and attributes["arrival_time_planned"] != attributes["arrival_time_actual"] - ): - attributes["arrival_delay"] = True - - # Next trip attributes - if next_trip: - if next_trip.departure_time_actual is not None: - attributes["next"] = next_trip.departure_time_actual.strftime("%H:%M") - elif next_trip.departure_time_planned is not None: - attributes["next"] = next_trip.departure_time_planned.strftime("%H:%M") - - return attributes diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index e2b9f8631d838..f268044c10368 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -441,7 +441,7 @@ def _async_abort_if_host_already_in_progress(self) -> None: def is_matching(self, other_flow: Self) -> bool: """Return True if other_flow is matching this flow.""" - return other_flow._host == self._host # noqa: SLF001 + return getattr(other_flow, "_host", None) == self._host @callback def _abort_if_manufacturer_is_not_samsung(self) -> None: diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 4e6fe260afca7..1abf35a7022ac 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -372,7 +372,7 @@ class _CustomDPCodeWrapper(DPCodeWrapper): _valid_values: set[bool | float | int | str] def __init__( - self, dpcode: str, valid_values: set[bool | float | int | str] + self, dpcode: DPCode, valid_values: set[bool | float | int | str] ) -> None: """Init CustomDPCodeBooleanWrapper.""" super().__init__(dpcode) @@ -390,7 +390,7 @@ def _get_dpcode_wrapper( description: TuyaBinarySensorEntityDescription, ) -> DPCodeWrapper | None: """Get DPCode wrapper for an entity description.""" - dpcode = description.dpcode or description.key + dpcode = description.dpcode or DPCode(description.key) if description.bitmap_key is not None: return DPCodeBitmapBitWrapper.find_dpcode( device, dpcode, bitmap_key=description.bitmap_key diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 29e457eb49097..8dfb479140d67 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -34,7 +34,6 @@ DPCodeEnumWrapper, DPCodeIntegerWrapper, IntegerTypeData, - find_dpcode, ) from .util import get_dpcode, get_dptype, remap_value @@ -108,6 +107,35 @@ def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any return round(self.type_information.remap_value_from(value)) +class _ColorTempWrapper(DPCodeIntegerWrapper): + """Wrapper for color temperature DP code.""" + + def read_device_status(self, device: CustomerDevice) -> Any | None: + """Return the color temperature value in Kelvin.""" + if (temperature := self._read_device_status_raw(device)) is None: + return None + + return color_util.color_temperature_mired_to_kelvin( + self.type_information.remap_value_to( + temperature, + MIN_MIREDS, + MAX_MIREDS, + reverse=True, + ) + ) + + def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: + """Convert a Home Assistant value (Kelvin) back to a raw device value.""" + return round( + self.type_information.remap_value_from( + color_util.color_temperature_kelvin_to_mired(value), + MIN_MIREDS, + MAX_MIREDS, + reverse=True, + ) + ) + + @dataclass class ColorTypeData: """Color Type Data.""" @@ -118,15 +146,27 @@ class ColorTypeData: DEFAULT_COLOR_TYPE_DATA = ColorTypeData( - h_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1), - s_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1), - v_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1), + h_type=IntegerTypeData( + dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1 + ), + s_type=IntegerTypeData( + dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1 + ), + v_type=IntegerTypeData( + dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1 + ), ) DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData( - h_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1), - s_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1), - v_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1), + h_type=IntegerTypeData( + dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1 + ), + s_type=IntegerTypeData( + dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1 + ), + v_type=IntegerTypeData( + dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1 + ), ) MAX_MIREDS = 500 # 2000 K @@ -529,6 +569,9 @@ def async_discover_device(device_ids: list[str]): color_mode_wrapper=DPCodeEnumWrapper.find_dpcode( device, description.color_mode, prefer_function=True ), + color_temp_wrapper=_ColorTempWrapper.find_dpcode( + device, description.color_temp, prefer_function=True + ), switch_wrapper=switch_wrapper, ) for description in descriptions @@ -555,7 +598,6 @@ class TuyaLightEntity(TuyaEntity, LightEntity): _color_data_dpcode: DPCode | None = None _color_data_type: ColorTypeData | None = None - _color_temp: IntegerTypeData | None = None _white_color_mode = ColorMode.COLOR_TEMP _fixed_color_mode: ColorMode | None = None _attr_min_color_temp_kelvin = 2000 # 500 Mireds @@ -567,8 +609,9 @@ def __init__( device_manager: Manager, description: TuyaLightEntityDescription, *, - brightness_wrapper: DPCodeIntegerWrapper | None, + brightness_wrapper: _BrightnessWrapper | None, color_mode_wrapper: DPCodeEnumWrapper | None, + color_temp_wrapper: _ColorTempWrapper | None, switch_wrapper: DPCodeBooleanWrapper, ) -> None: """Init TuyaHaLight.""" @@ -577,6 +620,7 @@ def __init__( self._attr_unique_id = f"{super().unique_id}{description.key}" self._brightness_wrapper = brightness_wrapper self._color_mode_wrapper = color_mode_wrapper + self._color_temp_wrapper = color_temp_wrapper self._switch_wrapper = switch_wrapper color_modes: set[ColorMode] = {ColorMode.ONOFF} @@ -597,9 +641,15 @@ def __init__( # Fetch color data type information if function_data := json_loads_object(values): self._color_data_type = ColorTypeData( - h_type=IntegerTypeData(dpcode, **cast(dict, function_data["h"])), - s_type=IntegerTypeData(dpcode, **cast(dict, function_data["s"])), - v_type=IntegerTypeData(dpcode, **cast(dict, function_data["v"])), + h_type=IntegerTypeData( + dpcode=dpcode, **cast(dict, function_data["h"]) + ), + s_type=IntegerTypeData( + dpcode=dpcode, **cast(dict, function_data["s"]) + ), + v_type=IntegerTypeData( + dpcode=dpcode, **cast(dict, function_data["v"]) + ), ) else: # If no type is found, use a default one @@ -611,13 +661,7 @@ def __init__( self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2 # Check if the light has color temperature - if int_type := find_dpcode( - self.device, - description.color_temp, - dptype=DPType.INTEGER, - prefer_function=True, - ): - self._color_temp = int_type + if color_temp_wrapper: color_modes.add(ColorMode.COLOR_TEMP) # If light has color but does not have color_temp, check if it has # work_mode "white" @@ -654,21 +698,11 @@ def turn_on(self, **kwargs: Any) -> None: ), ] - if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs: + if self._color_temp_wrapper and ATTR_COLOR_TEMP_KELVIN in kwargs: commands += [ - { - "code": self._color_temp.dpcode, - "value": round( - self._color_temp.remap_value_from( - color_util.color_temperature_kelvin_to_mired( - kwargs[ATTR_COLOR_TEMP_KELVIN] - ), - MIN_MIREDS, - MAX_MIREDS, - reverse=True, - ) - ), - }, + self._color_temp_wrapper.get_update_command( + self.device, kwargs[ATTR_COLOR_TEMP_KELVIN] + ) ] if self._color_data_type and ( @@ -748,18 +782,7 @@ def brightness(self) -> int | None: @property def color_temp_kelvin(self) -> int | None: """Return the color temperature value in Kelvin.""" - if not self._color_temp: - return None - - temperature = self.device.status.get(self._color_temp.dpcode) - if temperature is None: - return None - - return color_util.color_temperature_mired_to_kelvin( - self._color_temp.remap_value_to( - temperature, MIN_MIREDS, MAX_MIREDS, reverse=True - ) - ) + return self._read_wrapper(self._color_temp_wrapper) @property def hs_color(self) -> tuple[float, float] | None: diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py index 7953bcf7c8151..80cede7d339f1 100644 --- a/homeassistant/components/tuya/models.py +++ b/homeassistant/components/tuya/models.py @@ -15,7 +15,7 @@ from .util import parse_dptype, remap_value -@dataclass +@dataclass(kw_only=True) class TypeInformation: """Type information. @@ -23,14 +23,15 @@ class TypeInformation: """ dpcode: DPCode + type_data: str | None = None @classmethod - def from_json(cls, dpcode: DPCode, data: str) -> Self | None: + def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None: """Load JSON string and return a TypeInformation object.""" - return cls(dpcode) + return cls(dpcode=dpcode, type_data=type_data) -@dataclass +@dataclass(kw_only=True) class IntegerTypeData(TypeInformation): """Integer Type Data.""" @@ -84,13 +85,14 @@ def remap_value_from( return remap_value(value, from_min, from_max, self.min, self.max, reverse) @classmethod - def from_json(cls, dpcode: DPCode, data: str) -> Self | None: + def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None: """Load JSON string and return a IntegerTypeData object.""" - if not (parsed := cast(dict[str, Any] | None, json_loads_object(data))): + if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))): return None return cls( - dpcode, + dpcode=dpcode, + type_data=type_data, min=int(parsed["min"]), max=int(parsed["max"]), scale=int(parsed["scale"]), @@ -99,32 +101,40 @@ def from_json(cls, dpcode: DPCode, data: str) -> Self | None: ) -@dataclass +@dataclass(kw_only=True) class BitmapTypeInformation(TypeInformation): """Bitmap type information.""" label: list[str] @classmethod - def from_json(cls, dpcode: DPCode, data: str) -> Self | None: + def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None: """Load JSON string and return a BitmapTypeInformation object.""" - if not (parsed := json_loads_object(data)): + if not (parsed := json_loads_object(type_data)): return None - return cls(dpcode, **cast(dict[str, list[str]], parsed)) + return cls( + dpcode=dpcode, + type_data=type_data, + **cast(dict[str, list[str]], parsed), + ) -@dataclass +@dataclass(kw_only=True) class EnumTypeData(TypeInformation): """Enum Type Data.""" range: list[str] @classmethod - def from_json(cls, dpcode: DPCode, data: str) -> Self | None: + def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None: """Load JSON string and return a EnumTypeData object.""" - if not (parsed := json_loads_object(data)): + if not (parsed := json_loads_object(type_data)): return None - return cls(dpcode, **cast(dict[str, list[str]], parsed)) + return cls( + dpcode=dpcode, + type_data=type_data, + **cast(dict[str, list[str]], parsed), + ) _TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = { @@ -147,7 +157,7 @@ class DPCodeWrapper(ABC): native_unit: str | None = None suggested_unit: str | None = None - def __init__(self, dpcode: str) -> None: + def __init__(self, dpcode: DPCode) -> None: """Init DPCodeWrapper.""" self.dpcode = dpcode @@ -190,7 +200,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper): DPTYPE: DPType type_information: T - def __init__(self, dpcode: str, type_information: T) -> None: + def __init__(self, dpcode: DPCode, type_information: T) -> None: """Init DPCodeWrapper.""" super().__init__(dpcode) self.type_information = type_information @@ -297,7 +307,7 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]): DPTYPE = DPType.INTEGER - def __init__(self, dpcode: str, type_information: IntegerTypeData) -> None: + def __init__(self, dpcode: DPCode, type_information: IntegerTypeData) -> None: """Init DPCodeIntegerWrapper.""" super().__init__(dpcode, type_information) self.native_unit = type_information.unit @@ -327,7 +337,7 @@ def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any class DPCodeBitmapBitWrapper(DPCodeWrapper): """Simple wrapper for a specific bit in bitmap values.""" - def __init__(self, dpcode: str, mask: int) -> None: + def __init__(self, dpcode: DPCode, mask: int) -> None: """Init DPCodeBitmapWrapper.""" super().__init__(dpcode) self._mask = mask @@ -428,7 +438,7 @@ def find_dpcode( and parse_dptype(current_definition.type) is dptype and ( type_information := type_information_cls.from_json( - dpcode, current_definition.values + dpcode=dpcode, type_data=current_definition.values ) ) ): diff --git a/homeassistant/components/vagner_pool/__init__.py b/homeassistant/components/vagner_pool/__init__.py new file mode 100644 index 0000000000000..d7c1192e49e21 --- /dev/null +++ b/homeassistant/components/vagner_pool/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: VÁGNER POOL.""" diff --git a/homeassistant/components/vagner_pool/manifest.json b/homeassistant/components/vagner_pool/manifest.json new file mode 100644 index 0000000000000..792a7049c5655 --- /dev/null +++ b/homeassistant/components/vagner_pool/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "vagner_pool", + "name": "V\u00c1GNER POOL", + "integration_type": "virtual", + "supported_by": "pooldose" +} diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 20b5f9a7182f8..14333c33be58e 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -492,6 +492,7 @@ async def _play_announcement( await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) except Exception: _LOGGER.exception("Unexpected error while playing announcement") + self._announcement = None raise finally: self._run_pipeline_task = None diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e22efdde2ad9e..e79a50ba69f5a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1136,6 +1136,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "cosori": { + "name": "Cosori", + "integration_type": "virtual", + "supported_by": "vesync" + }, "cozytouch": { "name": "Atlantic Cozytouch", "integration_type": "virtual", @@ -7191,6 +7196,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "vagner_pool": { + "name": "V\u00c1GNER POOL", + "integration_type": "virtual", + "supported_by": "pooldose" + }, "vallox": { "name": "Vallox", "integration_type": "hub", diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index aac8be5c0ff35..b9dd85d1c23a1 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -805,6 +805,8 @@ async def async_get_all_descriptions( continue description = {"fields": yaml_description.get("fields", {})} + if (target := yaml_description.get("target")) is not None: + description["target"] = target new_descriptions_cache[missing_trigger] = description diff --git a/requirements_all.txt b/requirements_all.txt index abd74e3c7fe01..d504da37fa551 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2269,7 +2269,7 @@ pypaperless==4.1.1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.9.2 +pypck==0.9.3 # homeassistant.components.pglab pypglab==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0dde3eb6b7ddf..11569c517c547 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1892,7 +1892,7 @@ pypalazzetti==0.1.20 pypaperless==4.1.1 # homeassistant.components.lcn -pypck==0.9.2 +pypck==0.9.3 # homeassistant.components.pglab pypglab==0.0.5 diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index 0282e970fabe5..b9ae7d88112af 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -6,7 +6,7 @@ import pypck from pypck import lcn_defs -from pypck.module import GroupConnection, ModuleConnection, Serials +from pypck.device import DeviceConnection, Serials import pytest from homeassistant.components.lcn import PchkConnectionManager @@ -22,7 +22,7 @@ LATEST_CONFIG_ENTRY_VERSION = (LcnFlowHandler.VERSION, LcnFlowHandler.MINOR_VERSION) -class MockModuleConnection(ModuleConnection): +class MockDeviceConnection(DeviceConnection): """Fake a LCN module connection.""" request_name = AsyncMock(return_value="TestModule") @@ -49,12 +49,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._serials_known.set() -class MockGroupConnection(GroupConnection): - """Fake a LCN group connection.""" - - send_command = AsyncMock(return_value=True) - - class MockPchkConnectionManager(PchkConnectionManager): """Fake connection handler.""" @@ -67,15 +61,10 @@ async def async_connect(self, timeout: int = 30) -> None: async def async_close(self) -> None: """Mock closing a connection to PCHK.""" - @patch.object(pypck.connection, "ModuleConnection", MockModuleConnection) - def get_module_conn(self, addr): - """Get LCN module connection.""" - return super().get_module_conn(addr) - - @patch.object(pypck.connection, "GroupConnection", MockGroupConnection) - def get_group_conn(self, addr): - """Get LCN group connection.""" - return super().get_group_conn(addr) + @patch.object(pypck.connection, "DeviceConnection", MockDeviceConnection) + def get_device_connection(self, addr): + """Get LCN device connection.""" + return super().get_device_connection(addr) scan_modules = AsyncMock() send_command = AsyncMock() diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index e44a620e33c97..91c8b1d5f0c94 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -29,7 +29,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from .conftest import MockConfigEntry, MockModuleConnection, init_integration +from .conftest import MockConfigEntry, MockDeviceConnection, init_integration from tests.common import snapshot_platform @@ -51,7 +51,7 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - """Test the hvac mode is set to heat.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: + with patch.object(MockDeviceConnection, "lock_regulator") as lock_regulator: await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, @@ -106,7 +106,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> """Test the hvac mode is set off.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: + with patch.object(MockDeviceConnection, "lock_regulator") as lock_regulator: state = hass.states.get("climate.testmodule_climate1") state.state = HVACMode.HEAT @@ -154,7 +154,7 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N """Test the temperature is set.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "var_abs") as var_abs: + with patch.object(MockDeviceConnection, "var_abs") as var_abs: state = hass.states.get("climate.testmodule_climate1") state.state = HVACMode.HEAT diff --git a/tests/components/lcn/test_cover.py b/tests/components/lcn/test_cover.py index 6c8ed622ad05f..914ff69febc19 100644 --- a/tests/components/lcn/test_cover.py +++ b/tests/components/lcn/test_cover.py @@ -32,7 +32,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockConfigEntry, MockModuleConnection, init_integration +from .conftest import MockConfigEntry, MockDeviceConnection, init_integration from tests.common import snapshot_platform @@ -60,7 +60,7 @@ async def test_outputs_open(hass: HomeAssistant, entry: MockConfigEntry) -> None await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motor_outputs" + MockDeviceConnection, "control_motor_outputs" ) as control_motor_outputs: state = hass.states.get(COVER_OUTPUTS) assert state is not None @@ -109,7 +109,7 @@ async def test_outputs_close(hass: HomeAssistant, entry: MockConfigEntry) -> Non await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motor_outputs" + MockDeviceConnection, "control_motor_outputs" ) as control_motor_outputs: await hass.services.async_call( DOMAIN_COVER, @@ -161,7 +161,7 @@ async def test_outputs_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motor_outputs" + MockDeviceConnection, "control_motor_outputs" ) as control_motor_outputs: await hass.services.async_call( DOMAIN_COVER, @@ -209,7 +209,7 @@ async def test_relays_open(hass: HomeAssistant, entry: MockConfigEntry) -> None: await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motor_relays" + MockDeviceConnection, "control_motor_relays" ) as control_motor_relays: state = hass.states.get(COVER_RELAYS) assert state is not None @@ -258,7 +258,7 @@ async def test_relays_close(hass: HomeAssistant, entry: MockConfigEntry) -> None await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motor_relays" + MockDeviceConnection, "control_motor_relays" ) as control_motor_relays: await hass.services.async_call( DOMAIN_COVER, @@ -310,7 +310,7 @@ async def test_relays_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None: await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motor_relays" + MockDeviceConnection, "control_motor_relays" ) as control_motor_relays: await hass.services.async_call( DOMAIN_COVER, @@ -375,7 +375,7 @@ async def test_relays_set_position( await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motor_relays_position" + MockDeviceConnection, "control_motor_relays_position" ) as control_motor_relays_position: state = hass.states.get(entity_id) assert state is not None diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index 0d1bf2619bb3b..3e7705df897eb 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockConfigEntry, MockModuleConnection, init_integration +from .conftest import MockConfigEntry, MockDeviceConnection, init_integration from tests.common import snapshot_platform @@ -51,7 +51,7 @@ async def test_output_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> No """Test the output light turns on.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "toggle_output") as toggle_output: + with patch.object(MockDeviceConnection, "toggle_output") as toggle_output: # command failed toggle_output.return_value = False @@ -92,7 +92,7 @@ async def test_output_turn_on_with_attributes( """Test the output light turns on.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "dim_output") as dim_output: + with patch.object(MockDeviceConnection, "dim_output") as dim_output: dim_output.return_value = True await hass.services.async_call( @@ -117,7 +117,7 @@ async def test_output_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> N """Test the output light turns off.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "toggle_output") as toggle_output: + with patch.object(MockDeviceConnection, "toggle_output") as toggle_output: await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_ON, @@ -163,7 +163,7 @@ async def test_relay_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> Non """Test the relay light turns on.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "control_relays") as control_relays: + with patch.object(MockDeviceConnection, "control_relays") as control_relays: states = [RelayStateModifier.NOCHANGE] * 8 states[0] = RelayStateModifier.ON @@ -205,7 +205,7 @@ async def test_relay_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> No """Test the relay light turns off.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "control_relays") as control_relays: + with patch.object(MockDeviceConnection, "control_relays") as control_relays: states = [RelayStateModifier.NOCHANGE] * 8 states[0] = RelayStateModifier.OFF diff --git a/tests/components/lcn/test_scene.py b/tests/components/lcn/test_scene.py index aaf17f292c198..d26acff1a71d1 100644 --- a/tests/components/lcn/test_scene.py +++ b/tests/components/lcn/test_scene.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockConfigEntry, MockModuleConnection, init_integration +from .conftest import MockConfigEntry, MockDeviceConnection, init_integration from tests.common import snapshot_platform @@ -39,7 +39,7 @@ async def test_scene_activate( ) -> None: """Test the scene is activated.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "activate_scene") as activate_scene: + with patch.object(MockDeviceConnection, "activate_scene") as activate_scene: await hass.services.async_call( DOMAIN_SCENE, SERVICE_TURN_ON, diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index 46ede8959ff6b..2987fd0064ed4 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -35,7 +35,7 @@ from .conftest import ( MockConfigEntry, - MockModuleConnection, + MockDeviceConnection, get_device, init_integration, ) @@ -49,7 +49,7 @@ async def test_service_output_abs( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "dim_output") as dim_output: + with patch.object(MockDeviceConnection, "dim_output") as dim_output: await hass.services.async_call( DOMAIN, LcnService.OUTPUT_ABS, @@ -73,7 +73,7 @@ async def test_service_output_rel( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "rel_output") as rel_output: + with patch.object(MockDeviceConnection, "rel_output") as rel_output: await hass.services.async_call( DOMAIN, LcnService.OUTPUT_REL, @@ -96,7 +96,7 @@ async def test_service_output_toggle( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "toggle_output") as toggle_output: + with patch.object(MockDeviceConnection, "toggle_output") as toggle_output: await hass.services.async_call( DOMAIN, LcnService.OUTPUT_TOGGLE, @@ -119,7 +119,7 @@ async def test_service_relays( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "control_relays") as control_relays: + with patch.object(MockDeviceConnection, "control_relays") as control_relays: await hass.services.async_call( DOMAIN, LcnService.RELAYS, @@ -137,7 +137,7 @@ async def test_service_relays( # wrong states string with ( - patch.object(MockModuleConnection, "control_relays") as control_relays, + patch.object(MockDeviceConnection, "control_relays") as control_relays, pytest.raises(HomeAssistantError) as exc_info, ): await hass.services.async_call( @@ -161,7 +161,7 @@ async def test_service_led( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "control_led") as control_led: + with patch.object(MockDeviceConnection, "control_led") as control_led: await hass.services.async_call( DOMAIN, LcnService.LED, @@ -187,7 +187,7 @@ async def test_service_var_abs( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "var_abs") as var_abs: + with patch.object(MockDeviceConnection, "var_abs") as var_abs: await hass.services.async_call( DOMAIN, LcnService.VAR_ABS, @@ -213,7 +213,7 @@ async def test_service_var_rel( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "var_rel") as var_rel: + with patch.object(MockDeviceConnection, "var_rel") as var_rel: await hass.services.async_call( DOMAIN, LcnService.VAR_REL, @@ -243,7 +243,7 @@ async def test_service_var_reset( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "var_reset") as var_reset: + with patch.object(MockDeviceConnection, "var_reset") as var_reset: await hass.services.async_call( DOMAIN, LcnService.VAR_RESET, @@ -265,7 +265,7 @@ async def test_service_lock_regulator( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: + with patch.object(MockDeviceConnection, "lock_regulator") as lock_regulator: await hass.services.async_call( DOMAIN, LcnService.LOCK_REGULATOR, @@ -288,7 +288,7 @@ async def test_service_send_keys( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "send_keys") as send_keys: + with patch.object(MockDeviceConnection, "send_keys") as send_keys: await hass.services.async_call( DOMAIN, LcnService.SEND_KEYS, @@ -323,7 +323,7 @@ async def test_service_send_keys_hit_deferred( # success with patch.object( - MockModuleConnection, "send_keys_hit_deferred" + MockDeviceConnection, "send_keys_hit_deferred" ) as send_keys_hit_deferred: await hass.services.async_call( DOMAIN, @@ -344,7 +344,7 @@ async def test_service_send_keys_hit_deferred( # wrong key action with ( patch.object( - MockModuleConnection, "send_keys_hit_deferred" + MockDeviceConnection, "send_keys_hit_deferred" ) as send_keys_hit_deferred, pytest.raises(ServiceValidationError) as exc_info, ): @@ -372,7 +372,7 @@ async def test_service_lock_keys( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "lock_keys") as lock_keys: + with patch.object(MockDeviceConnection, "lock_keys") as lock_keys: await hass.services.async_call( DOMAIN, LcnService.LOCK_KEYS, @@ -391,7 +391,7 @@ async def test_service_lock_keys( # wrong states string with ( - patch.object(MockModuleConnection, "lock_keys") as lock_keys, + patch.object(MockDeviceConnection, "lock_keys") as lock_keys, pytest.raises(HomeAssistantError) as exc_info, ): await hass.services.async_call( @@ -418,7 +418,7 @@ async def test_service_lock_keys_tab_a_temporary( # success with patch.object( - MockModuleConnection, "lock_keys_tab_a_temporary" + MockDeviceConnection, "lock_keys_tab_a_temporary" ) as lock_keys_tab_a_temporary: await hass.services.async_call( DOMAIN, @@ -442,7 +442,7 @@ async def test_service_lock_keys_tab_a_temporary( # wrong table with ( patch.object( - MockModuleConnection, "lock_keys_tab_a_temporary" + MockDeviceConnection, "lock_keys_tab_a_temporary" ) as lock_keys_tab_a_temporary, pytest.raises(ServiceValidationError) as exc_info, ): @@ -470,7 +470,7 @@ async def test_service_dyn_text( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "dyn_text") as dyn_text: + with patch.object(MockDeviceConnection, "dyn_text") as dyn_text: await hass.services.async_call( DOMAIN, LcnService.DYN_TEXT, @@ -493,7 +493,7 @@ async def test_service_pck( await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) - with patch.object(MockModuleConnection, "pck") as pck: + with patch.object(MockDeviceConnection, "pck") as pck: await hass.services.async_call( DOMAIN, LcnService.PCK, diff --git a/tests/components/lcn/test_switch.py b/tests/components/lcn/test_switch.py index 9f314efe6c4bb..3187f4ab6e6a5 100644 --- a/tests/components/lcn/test_switch.py +++ b/tests/components/lcn/test_switch.py @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockConfigEntry, MockModuleConnection, init_integration +from .conftest import MockConfigEntry, MockDeviceConnection, init_integration from tests.common import snapshot_platform @@ -55,7 +55,7 @@ async def test_output_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> No """Test the output switch turns on.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "dim_output") as dim_output: + with patch.object(MockDeviceConnection, "dim_output") as dim_output: # command failed dim_output.return_value = False @@ -92,7 +92,7 @@ async def test_output_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> N """Test the output switch turns off.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "dim_output") as dim_output: + with patch.object(MockDeviceConnection, "dim_output") as dim_output: await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_ON, @@ -136,7 +136,7 @@ async def test_relay_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> Non """Test the relay switch turns on.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "control_relays") as control_relays: + with patch.object(MockDeviceConnection, "control_relays") as control_relays: states = [RelayStateModifier.NOCHANGE] * 8 states[0] = RelayStateModifier.ON @@ -176,7 +176,7 @@ async def test_relay_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> No """Test the relay switch turns off.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "control_relays") as control_relays: + with patch.object(MockDeviceConnection, "control_relays") as control_relays: states = [RelayStateModifier.NOCHANGE] * 8 states[0] = RelayStateModifier.OFF @@ -225,7 +225,7 @@ async def test_regulatorlock_turn_on( """Test the regulator lock switch turns on.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: + with patch.object(MockDeviceConnection, "lock_regulator") as lock_regulator: # command failed lock_regulator.return_value = False @@ -264,7 +264,7 @@ async def test_regulatorlock_turn_off( """Test the regulator lock switch turns off.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: + with patch.object(MockDeviceConnection, "lock_regulator") as lock_regulator: await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_ON, @@ -308,7 +308,7 @@ async def test_keylock_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> N """Test the keylock switch turns on.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "lock_keys") as lock_keys: + with patch.object(MockDeviceConnection, "lock_keys") as lock_keys: states = [KeyLockStateModifier.NOCHANGE] * 8 states[0] = KeyLockStateModifier.ON @@ -348,7 +348,7 @@ async def test_keylock_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> """Test the keylock switch turns off.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "lock_keys") as lock_keys: + with patch.object(MockDeviceConnection, "lock_keys") as lock_keys: states = [KeyLockStateModifier.NOCHANGE] * 8 states[0] = KeyLockStateModifier.OFF diff --git a/tests/components/lcn/test_websocket.py b/tests/components/lcn/test_websocket.py index 75d8a605bfb5e..2dd27c0387b03 100644 --- a/tests/components/lcn/test_websocket.py +++ b/tests/components/lcn/test_websocket.py @@ -118,7 +118,7 @@ async def test_lcn_devices_scan_command( """Test lcn/devices/scan command.""" # add new module which is not stored in config_entry lcn_connection = await init_integration(hass, entry) - lcn_connection.get_address_conn(LcnAddr(0, 10, False)) + lcn_connection.get_device_connection(LcnAddr(0, 10, False)) client = await hass_ws_client(hass) await client.send_json_auto_id({**SCAN_PAYLOAD, "entry_id": entry.entry_id}) diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 2b2ba5dac0c57..e61f2ec88f6c1 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -137,7 +137,7 @@ async def integration_fixture( "switch_unit", "tado_smart_radiator_thermostat_x", "temperature_sensor", - "thermostat", + "longan_link_thermostat", "vacuum_cleaner", "valve", "window_covering_full", diff --git a/tests/components/matter/fixtures/nodes/thermostat.json b/tests/components/matter/fixtures/nodes/longan_link_thermostat.json similarity index 100% rename from tests/components/matter/fixtures/nodes/thermostat.json rename to tests/components/matter/fixtures/nodes/longan_link_thermostat.json diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index eb1235c54efc9..e7c77835313fe 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -440,6 +440,55 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[longan_link_thermostat][binary_sensor.longan_link_hvac_occupancy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.longan_link_hvac_occupancy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Occupancy', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatOccupancySensor-513-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[longan_link_thermostat][binary_sensor.longan_link_hvac_occupancy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'occupancy', + 'friendly_name': 'Longan link HVAC Occupancy', + }), + 'context': , + 'entity_id': 'binary_sensor.longan_link_hvac_occupancy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[occupancy_sensor][binary_sensor.mock_occupancy_sensor_occupancy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1320,55 +1369,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[thermostat][binary_sensor.longan_link_hvac_occupancy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.longan_link_hvac_occupancy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Occupancy', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatOccupancySensor-513-2', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[thermostat][binary_sensor.longan_link_hvac_occupancy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'occupancy', - 'friendly_name': 'Longan link HVAC Occupancy', - }), - 'context': , - 'entity_id': 'binary_sensor.longan_link_hvac_occupancy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_binary_sensors[valve][binary_sensor.valve_general_fault-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index e44e3641fa8a4..a9ba27ac0b71d 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -2000,6 +2000,55 @@ 'state': 'unknown', }) # --- +# name: test_buttons[longan_link_thermostat][button.longan_link_hvac_identify-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.longan_link_hvac_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[longan_link_thermostat][button.longan_link_hvac_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Longan link HVAC Identify', + }), + 'context': , + 'entity_id': 'button.longan_link_hvac_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[microwave_oven][button.microwave_oven_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3457,55 +3506,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[thermostat][button.longan_link_hvac_identify-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.longan_link_hvac_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[thermostat][button.longan_link_hvac_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Longan link HVAC Identify', - }), - 'context': , - 'entity_id': 'button.longan_link_hvac_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[window_covering_pa_lift][button.longan_link_wncv_da01_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index e63553db55924..5b5f9cd3f8d7b 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -255,7 +255,7 @@ 'state': 'heat', }) # --- -# name: test_climates[room_airconditioner][climate.room_airconditioner-entry] +# name: test_climates[longan_link_thermostat][climate.longan_link_hvac-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -265,12 +265,10 @@ , , , - , - , , ]), - 'max_temp': 32.0, - 'min_temp': 16.0, + 'max_temp': 35, + 'min_temp': 7, }), 'config_entry_id': , 'config_subentry_id': , @@ -279,7 +277,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.room_airconditioner', + 'entity_id': 'climate.longan_link_hvac', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -295,39 +293,39 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterThermostat-513-0', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) # --- -# name: test_climates[room_airconditioner][climate.room_airconditioner-state] +# name: test_climates[longan_link_thermostat][climate.longan_link_hvac-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_temperature': 20.0, - 'friendly_name': 'Room AirConditioner', + 'current_temperature': 28.3, + 'friendly_name': 'Longan link HVAC', 'hvac_modes': list([ , , , - , - , , ]), - 'max_temp': 32.0, - 'min_temp': 16.0, - 'supported_features': , - 'temperature': 20.0, + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, }), 'context': , - 'entity_id': 'climate.room_airconditioner', + 'entity_id': 'climate.longan_link_hvac', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'cool', }) # --- -# name: test_climates[tado_smart_radiator_thermostat_x][climate.smart_radiator_thermostat_x-entry] +# name: test_climates[room_airconditioner][climate.room_airconditioner-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -336,9 +334,13 @@ 'hvac_modes': list([ , , + , + , + , + , ]), - 'max_temp': 30.0, - 'min_temp': 5.0, + 'max_temp': 32.0, + 'min_temp': 16.0, }), 'config_entry_id': , 'config_subentry_id': , @@ -347,7 +349,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.smart_radiator_thermostat_x', + 'entity_id': 'climate.room_airconditioner', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -365,34 +367,37 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0', + 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) # --- -# name: test_climates[tado_smart_radiator_thermostat_x][climate.smart_radiator_thermostat_x-state] +# name: test_climates[room_airconditioner][climate.room_airconditioner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_humidity': 74.92, - 'current_temperature': 20.9, - 'friendly_name': 'Smart Radiator Thermostat X', + 'current_temperature': 20.0, + 'friendly_name': 'Room AirConditioner', 'hvac_modes': list([ , , + , + , + , + , ]), - 'max_temp': 30.0, - 'min_temp': 5.0, + 'max_temp': 32.0, + 'min_temp': 16.0, 'supported_features': , - 'temperature': 18.0, + 'temperature': 20.0, }), 'context': , - 'entity_id': 'climate.smart_radiator_thermostat_x', + 'entity_id': 'climate.room_airconditioner', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_climates[thermostat][climate.longan_link_hvac-entry] +# name: test_climates[tado_smart_radiator_thermostat_x][climate.smart_radiator_thermostat_x-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -401,11 +406,9 @@ 'hvac_modes': list([ , , - , - , ]), - 'max_temp': 35, - 'min_temp': 7, + 'max_temp': 30.0, + 'min_temp': 5.0, }), 'config_entry_id': , 'config_subentry_id': , @@ -414,7 +417,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.longan_link_hvac', + 'entity_id': 'climate.smart_radiator_thermostat_x', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -430,35 +433,32 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterThermostat-513-0', + 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) # --- -# name: test_climates[thermostat][climate.longan_link_hvac-state] +# name: test_climates[tado_smart_radiator_thermostat_x][climate.smart_radiator_thermostat_x-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_temperature': 28.3, - 'friendly_name': 'Longan link HVAC', + 'current_humidity': 74.92, + 'current_temperature': 20.9, + 'friendly_name': 'Smart Radiator Thermostat X', 'hvac_modes': list([ , , - , - , ]), - 'max_temp': 35, - 'min_temp': 7, - 'supported_features': , - 'target_temp_high': None, - 'target_temp_low': None, - 'temperature': None, + 'max_temp': 30.0, + 'min_temp': 5.0, + 'supported_features': , + 'temperature': 18.0, }), 'context': , - 'entity_id': 'climate.longan_link_hvac', + 'entity_id': 'climate.smart_radiator_thermostat_x', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'cool', + 'state': 'off', }) # --- diff --git a/tests/components/matter/snapshots/test_fan.ambr b/tests/components/matter/snapshots/test_fan.ambr index c3f859ff8ae38..1ec4ad214282b 100644 --- a/tests/components/matter/snapshots/test_fan.ambr +++ b/tests/components/matter/snapshots/test_fan.ambr @@ -200,7 +200,7 @@ 'state': 'off', }) # --- -# name: test_fans[room_airconditioner][fan.room_airconditioner-entry] +# name: test_fans[longan_link_thermostat][fan.longan_link_hvac-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -211,7 +211,6 @@ 'medium', 'high', 'auto', - 'sleep_wind', ]), }), 'config_entry_id': , @@ -221,7 +220,7 @@ 'disabled_by': None, 'domain': 'fan', 'entity_category': None, - 'entity_id': 'fan.room_airconditioner', + 'entity_id': 'fan.longan_link_hvac', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -237,37 +236,34 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterFan-514-0', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterFan-514-0', 'unit_of_measurement': None, }) # --- -# name: test_fans[room_airconditioner][fan.room_airconditioner-state] +# name: test_fans[longan_link_thermostat][fan.longan_link_hvac-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Room AirConditioner', - 'percentage': 0, - 'percentage_step': 33.333333333333336, + 'friendly_name': 'Longan link HVAC', 'preset_mode': None, 'preset_modes': list([ 'low', 'medium', 'high', 'auto', - 'sleep_wind', ]), - 'supported_features': , + 'supported_features': , }), 'context': , - 'entity_id': 'fan.room_airconditioner', + 'entity_id': 'fan.longan_link_hvac', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_fans[thermostat][fan.longan_link_hvac-entry] +# name: test_fans[room_airconditioner][fan.room_airconditioner-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -278,6 +274,7 @@ 'medium', 'high', 'auto', + 'sleep_wind', ]), }), 'config_entry_id': , @@ -287,7 +284,7 @@ 'disabled_by': None, 'domain': 'fan', 'entity_category': None, - 'entity_id': 'fan.longan_link_hvac', + 'entity_id': 'fan.room_airconditioner', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -303,27 +300,30 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterFan-514-0', + 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterFan-514-0', 'unit_of_measurement': None, }) # --- -# name: test_fans[thermostat][fan.longan_link_hvac-state] +# name: test_fans[room_airconditioner][fan.room_airconditioner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Longan link HVAC', + 'friendly_name': 'Room AirConditioner', + 'percentage': 0, + 'percentage_step': 33.333333333333336, 'preset_mode': None, 'preset_modes': list([ 'low', 'medium', 'high', 'auto', + 'sleep_wind', ]), - 'supported_features': , + 'supported_features': , }), 'context': , - 'entity_id': 'fan.longan_link_hvac', + 'entity_id': 'fan.room_airconditioner', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index fef1a1b5209bd..d8a5449ac7d4d 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -2127,6 +2127,63 @@ 'state': 'Low', }) # --- +# name: test_selects[longan_link_thermostat][select.longan_link_hvac_temperature_display_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.longan_link_hvac_temperature_display_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': 'Temperature display mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_mode', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[longan_link_thermostat][select.longan_link_hvac_temperature_display_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Longan link HVAC Temperature display mode', + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.longan_link_hvac_temperature_display_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Celsius', + }) +# --- # name: test_selects[microwave_oven][select.microwave_oven_power_level_w-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3826,63 +3883,6 @@ 'state': 'Quick', }) # --- -# name: test_selects[thermostat][select.longan_link_hvac_temperature_display_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'Celsius', - 'Fahrenheit', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.longan_link_hvac_temperature_display_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': 'Temperature display mode', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'temperature_display_mode', - 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[thermostat][select.longan_link_hvac_temperature_display_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Longan link HVAC Temperature display mode', - 'options': list([ - 'Celsius', - 'Fahrenheit', - ]), - }), - 'context': , - 'entity_id': 'select.longan_link_hvac_temperature_display_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Celsius', - }) -# --- # name: test_selects[vacuum_cleaner][select.mock_vacuum_clean_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 089db73bc151a..fc97e545549f8 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -6974,6 +6974,118 @@ 'state': '1.3', }) # --- +# name: test_sensors[longan_link_thermostat][sensor.longan_link_hvac_outdoor_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.longan_link_hvac_outdoor_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': 'Outdoor temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatOutdoorTemperature-513-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[longan_link_thermostat][sensor.longan_link_hvac_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Longan link HVAC Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.longan_link_hvac_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.5', + }) +# --- +# name: test_sensors[longan_link_thermostat][sensor.longan_link_hvac_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.longan_link_hvac_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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[longan_link_thermostat][sensor.longan_link_hvac_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Longan link HVAC Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.longan_link_hvac_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.3', + }) +# --- # name: test_sensors[microwave_oven][sensor.microwave_oven_estimated_end_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -10451,118 +10563,6 @@ 'state': '21.0', }) # --- -# name: test_sensors[thermostat][sensor.longan_link_hvac_outdoor_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.longan_link_hvac_outdoor_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': 'Outdoor temperature', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'outdoor_temperature', - 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatOutdoorTemperature-513-1', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[thermostat][sensor.longan_link_hvac_outdoor_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Longan link HVAC Outdoor temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.longan_link_hvac_outdoor_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '12.5', - }) -# --- -# name: test_sensors[thermostat][sensor.longan_link_hvac_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.longan_link_hvac_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': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[thermostat][sensor.longan_link_hvac_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Longan link HVAC Temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.longan_link_hvac_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '28.3', - }) -# --- # name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_estimated_end_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 4fae8c8ac38a1..5763338419094 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -633,6 +633,55 @@ 'state': 'off', }) # --- +# name: test_switches[longan_link_thermostat][switch.longan_link_hvac-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.longan_link_hvac', + '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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterSwitch-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[longan_link_thermostat][switch.longan_link_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Longan link HVAC', + }), + 'context': , + 'entity_id': 'switch.longan_link_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[on_off_plugin_unit][switch.mock_onoffpluginunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1072,55 +1121,6 @@ 'state': 'off', }) # --- -# name: test_switches[thermostat][switch.longan_link_hvac-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.longan_link_hvac', - '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': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterSwitch-6-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_switches[thermostat][switch.longan_link_hvac-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Longan link HVAC', - }), - 'context': , - 'entity_id': 'switch.longan_link_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_switches[yandex_smart_socket][switch.yndx_00540-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 342ee42586377..d8dd406f2a1b0 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -388,7 +388,7 @@ async def test_water_valve( assert state.state == "on" -@pytest.mark.parametrize("node_fixture", ["thermostat"]) +@pytest.mark.parametrize("node_fixture", ["longan_link_thermostat"]) async def test_thermostat_occupancy( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 4e9afb4e6969d..c7a4cb05640e4 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -30,7 +30,7 @@ async def test_climates( snapshot_matter_entities(hass, entity_registry, snapshot, Platform.CLIMATE) -@pytest.mark.parametrize("node_fixture", ["thermostat"]) +@pytest.mark.parametrize("node_fixture", ["longan_link_thermostat"]) async def test_thermostat_base( hass: HomeAssistant, matter_client: MagicMock, @@ -162,7 +162,7 @@ async def test_thermostat_base( assert state.attributes["temperature"] == 20 -@pytest.mark.parametrize("node_fixture", ["thermostat"]) +@pytest.mark.parametrize("node_fixture", ["longan_link_thermostat"]) async def test_thermostat_humidity( hass: HomeAssistant, matter_client: MagicMock, @@ -215,7 +215,7 @@ async def test_thermostat_humidity( assert "current_humidity" not in state.attributes -@pytest.mark.parametrize("node_fixture", ["thermostat"]) +@pytest.mark.parametrize("node_fixture", ["longan_link_thermostat"]) async def test_thermostat_service_calls( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index b8421f337c92c..1b532d77e418a 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -233,7 +233,7 @@ async def test_eve_thermo_sensor( assert state.state == "18.0" -@pytest.mark.parametrize("node_fixture", ["thermostat"]) +@pytest.mark.parametrize("node_fixture", ["longan_link_thermostat"]) async def test_thermostat_outdoor( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/mobile_app/test_pending_updates.py b/tests/components/mobile_app/test_pending_updates.py new file mode 100644 index 0000000000000..0bcf8aba74e76 --- /dev/null +++ b/tests/components/mobile_app/test_pending_updates.py @@ -0,0 +1,622 @@ +"""Tests for mobile_app pending updates functionality.""" + +from http import HTTPStatus +from typing import Any + +from aiohttp.test_utils import TestClient + +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +async def test_pending_update_applied_when_entity_enabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that updates sent while disabled are applied when entity is re-enabled.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + # Register a sensor + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 100, + "type": "sensor", + "unique_id": "battery_state", + "unit_of_measurement": PERCENTAGE, + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "100" + + # Disable the entity + entity_registry.async_update_entity( + "sensor.test_1_battery_state", disabled_by=er.RegistryEntryDisabler.USER + ) + await hass.async_block_till_done() + + # Send update while disabled + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 50, + "type": "sensor", + "unique_id": "battery_state", + "unit_of_measurement": PERCENTAGE, + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + # Re-enable the entity + entity_registry.async_update_entity("sensor.test_1_battery_state", disabled_by=None) + + # Reload the config entry to trigger entity re-creation + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the update sent while disabled was applied + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "50" + + +async def test_pending_update_with_attributes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that pending updates preserve all attributes.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + # Register a sensor + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 100, + "type": "sensor", + "unique_id": "battery_state", + "attributes": {"charging": True, "voltage": 4.2}, + "icon": "mdi:battery-charging", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + # Disable the entity + entity_registry.async_update_entity( + "sensor.test_1_battery_state", disabled_by=er.RegistryEntryDisabler.USER + ) + await hass.async_block_till_done() + + # Send update with different attributes while disabled + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 50, + "type": "sensor", + "unique_id": "battery_state", + "attributes": {"charging": False, "voltage": 3.7}, + "icon": "mdi:battery-50", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + # Re-enable the entity + entity_registry.async_update_entity("sensor.test_1_battery_state", disabled_by=None) + + # Reload the config entry + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify all attributes were applied + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "50" + assert entity.attributes["charging"] is False + assert entity.attributes["voltage"] == 3.7 + assert entity.attributes["icon"] == "mdi:battery-50" + + +async def test_pending_update_overwritten_by_newer_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that newer pending updates overwrite older ones.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + # Register a sensor + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 100, + "type": "sensor", + "unique_id": "battery_state", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + # Disable the entity + entity_registry.async_update_entity( + "sensor.test_1_battery_state", disabled_by=er.RegistryEntryDisabler.USER + ) + await hass.async_block_till_done() + + # Send first update while disabled + await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 75, + "type": "sensor", + "unique_id": "battery_state", + }, + }, + ) + await hass.async_block_till_done() + + # Send second update while still disabled - should overwrite + await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 25, + "type": "sensor", + "unique_id": "battery_state", + }, + }, + ) + await hass.async_block_till_done() + + # Re-enable the entity + entity_registry.async_update_entity("sensor.test_1_battery_state", disabled_by=None) + + # Reload the config entry + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the latest update was applied (25, not 75) + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "25" + + +async def test_pending_update_not_stored_on_enabled_entities( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that enabled entities receive updates immediately.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + # Register a sensor + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 100, + "type": "sensor", + "unique_id": "battery_state", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "100" + + # Send update while enabled - should apply immediately + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 50, + "type": "sensor", + "unique_id": "battery_state", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + # Verify update was applied immediately + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "50" + + +async def test_pending_update_fallback_to_restore_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that restored state is used when no pending update exists.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + # Register a sensor + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 100, + "type": "sensor", + "unique_id": "battery_state", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "100" + + # Update to a new state + await webhook_client.post( + webhook_url, + json={ + "type": "update_sensor_states", + "data": [ + { + "state": 75, + "type": "sensor", + "unique_id": "battery_state", + } + ], + }, + ) + await hass.async_block_till_done() + + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "75" + + # Reload without pending updates + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify restored state was used + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "75" + + +async def test_multiple_pending_updates_for_different_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that multiple sensors can be updated while disabled and applied when re-enabled.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + # Register two sensors + for unique_id, state in (("battery_state", 100), ("battery_temp", 25)): + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": unique_id.replace("_", " ").title(), + "state": state, + "type": "sensor", + "unique_id": unique_id, + }, + }, + ) + assert reg_resp.status == HTTPStatus.CREATED + + await hass.async_block_till_done() + + # Disable both entities + entity_registry.async_update_entity( + "sensor.test_1_battery_state", disabled_by=er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + "sensor.test_1_battery_temp", disabled_by=er.RegistryEntryDisabler.USER + ) + await hass.async_block_till_done() + + # Send updates for both while disabled + await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 50, + "type": "sensor", + "unique_id": "battery_state", + }, + }, + ) + + await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery Temp", + "state": 30, + "type": "sensor", + "unique_id": "battery_temp", + }, + }, + ) + await hass.async_block_till_done() + + # Re-enable both entities + entity_registry.async_update_entity("sensor.test_1_battery_state", disabled_by=None) + entity_registry.async_update_entity("sensor.test_1_battery_temp", disabled_by=None) + + # Reload the config entry + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify both updates sent while disabled were applied + battery_state = hass.states.get("sensor.test_1_battery_state") + battery_temp = hass.states.get("sensor.test_1_battery_temp") + + assert battery_state is not None + assert battery_state.state == "50" + assert battery_temp is not None + assert battery_temp.state == "30" + + +async def test_update_sensor_states_with_pending_updates( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that update_sensor_states updates are applied when entity is re-enabled.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + # Register a sensor + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 100, + "type": "sensor", + "unique_id": "battery_state", + "unit_of_measurement": PERCENTAGE, + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "100" + + # Disable the entity + entity_registry.async_update_entity( + "sensor.test_1_battery_state", disabled_by=er.RegistryEntryDisabler.USER + ) + await hass.async_block_till_done() + + # Use update_sensor_states while disabled + resp = await webhook_client.post( + webhook_url, + json={ + "type": "update_sensor_states", + "data": [ + { + "state": 75, + "type": "sensor", + "unique_id": "battery_state", + } + ], + }, + ) + + assert resp.status == HTTPStatus.OK + await hass.async_block_till_done() + + # Re-enable the entity + entity_registry.async_update_entity("sensor.test_1_battery_state", disabled_by=None) + + # Reload the config entry to trigger entity re-creation + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the update sent while disabled was applied + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "75" + + +async def test_update_sensor_states_always_stores_pending( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that update_sensor_states applies updates to enabled entities.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + # Register a sensor + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 100, + "type": "sensor", + "unique_id": "battery_state", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "100" + + # Use update_sensor_states while enabled + resp = await webhook_client.post( + webhook_url, + json={ + "type": "update_sensor_states", + "data": [ + { + "state": 50, + "type": "sensor", + "unique_id": "battery_state", + } + ], + }, + ) + + assert resp.status == HTTPStatus.OK + await hass.async_block_till_done() + + # Verify update was applied + entity = hass.states.get("sensor.test_1_battery_state") + assert entity is not None + assert entity.state == "50" + + +async def test_binary_sensor_pending_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that binary sensor updates are applied when entity is re-enabled.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + # Register a binary sensor + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Motion Detected", + "state": False, + "type": "binary_sensor", + "unique_id": "motion_sensor", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.test_1_motion_detected") + assert entity is not None + assert entity.state == "off" + + # Disable the entity + entity_registry.async_update_entity( + "binary_sensor.test_1_motion_detected", + disabled_by=er.RegistryEntryDisabler.USER, + ) + await hass.async_block_till_done() + + # Send update while disabled + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Motion Detected", + "state": True, + "type": "binary_sensor", + "unique_id": "motion_sensor", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + await hass.async_block_till_done() + + # Re-enable the entity + entity_registry.async_update_entity( + "binary_sensor.test_1_motion_detected", disabled_by=None + ) + + # Reload the config entry + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the update sent while disabled was applied + entity = hass.states.get("binary_sensor.test_1_motion_detected") + assert entity is not None + assert entity.state == "on" diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 7c896a4f3e734..1ec669fbe86d5 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -30,8 +30,7 @@ SERVICE_UNJOIN, MediaPlayerEntityFeature, ) -from homeassistant.components.music_assistant.const import DOMAIN -from homeassistant.components.music_assistant.media_player import ( +from homeassistant.components.music_assistant.const import ( ATTR_ALBUM, ATTR_ANNOUNCE_VOLUME, ATTR_ARTIST, @@ -42,6 +41,9 @@ ATTR_SOURCE_PLAYER, ATTR_URL, ATTR_USE_PRE_ANNOUNCE, + DOMAIN, +) +from homeassistant.components.music_assistant.services import ( SERVICE_GET_QUEUE, SERVICE_PLAY_ANNOUNCEMENT, SERVICE_PLAY_MEDIA_ADVANCED, diff --git a/tests/components/nederlandse_spoorwegen/conftest.py b/tests/components/nederlandse_spoorwegen/conftest.py index 8729a0cd5b34d..100a720562223 100644 --- a/tests/components/nederlandse_spoorwegen/conftest.py +++ b/tests/components/nederlandse_spoorwegen/conftest.py @@ -56,6 +56,21 @@ def mock_nsapi() -> Generator[AsyncMock]: yield client +@pytest.fixture +def mock_single_trip_nsapi(mock_nsapi: AsyncMock) -> Generator[AsyncMock]: + """Override async_setup_entry.""" + trips_data = load_json_object_fixture("trip_single.json", DOMAIN) + mock_nsapi.get_trips.return_value = [Trip(trip) for trip in trips_data["trips"]] + return mock_nsapi + + +@pytest.fixture +def mock_no_trips_nsapi(mock_nsapi: AsyncMock) -> Generator[AsyncMock]: + """Override async_setup_entry.""" + mock_nsapi.get_trips.return_value = [] + return mock_nsapi + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock config entry.""" diff --git a/tests/components/nederlandse_spoorwegen/fixtures/trip_single.json b/tests/components/nederlandse_spoorwegen/fixtures/trip_single.json new file mode 100644 index 0000000000000..8846dc8172cd5 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/fixtures/trip_single.json @@ -0,0 +1,856 @@ +{ + "source": "HARP", + "trips": [ + { + "idx": 2, + "uid": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:34:00+02:00|plannedArrivalTime=2025-09-15T18:45:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=1596512355", + "ctxRecon": "arnu|fromStation=8400058|requestedFromStation=8400058|toStation=8400530|requestedToStation=8400530|viaStation=8400319|plannedFromTime=2025-09-15T16:34:00+02:00|plannedArrivalTime=2025-09-15T18:45:00+02:00|excludeHighSpeedTrains=false|searchForAccessibleTrip=false|localTrainsOnly=false|disabledTransportModalities=BUS,FERRY,TRAM,METRO|travelAssistance=false|tripSummaryHash=1596512355", + "sourceCtxRecon": "¶HKI¶T$A=1@O=Amsterdam Centraal@L=1100836@a=128@$A=1@O='s-Hertogenbosch@L=1100870@a=128@$202509151634$202509151731$IC 2761 $$1$$$$$$§W$A=1@O='s-Hertogenbosch@L=1100870@a=128@$A=1@O='s-Hertogenbosch@L=1101751@a=128@$202509151731$202509151733$$$1$$$$$$§T$A=1@O='s-Hertogenbosch@L=1101751@a=128@$A=1@O=Breda@L=1101034@a=128@$202509151741$202509151810$IC 3661 $$3$$$$$$§W$A=1@O=Breda@L=1101034@a=128@$A=1@O=Breda@L=1100942@a=128@$202509151810$202509151812$$$1$$$$$$§T$A=1@O=Breda@L=1100942@a=128@$A=1@O=Rotterdam Centraal@L=1100668@a=128@$202509151823$202509151845$IC 1162 $$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#16465#AM2#0#RT#31#¶KCC¶#VE#0#ERG#45317#HIN#390#ECK#13954|13954|14077|14085|0|0|485|13938|3|0|8|0|0|-2147483648#¶KRCC¶#VE#1#MRTF#", + "plannedDurationInMinutes": 131, + "actualDurationInMinutes": 130, + "transfers": 2, + "status": "NORMAL", + "messages": [], + "legs": [ + { + "idx": "0", + "name": "IC 2761", + "travelType": "PUBLIC_TRANSIT", + "direction": "Maastricht", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1088#TA#0#DA#150925#1S#1101009#1T#1557#LS#1101011#LT#1903#PU#784#RT#1#CA#IC#ZE#2761#ZB#IC 2761 #PC#1#FR#1101009#FT#1557#TO#1101011#TT#1903#", + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T16:34:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T16:35:00+0200", + "plannedTrack": "4", + "actualTrack": "4", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:31:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:31:00+0200", + "plannedTrack": "6", + "actualTrack": "6", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "2761", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Maastricht", + "shortValue": "richting Maastricht", + "accessibilityValue": "richting Maastricht", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "2 tussenstops", + "shortValue": "2 tussenstops", + "accessibilityValue": "2 tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "stops": [ + { + "uicCode": "8400058", + "uicCdCode": "118400058", + "name": "Amsterdam Centraal", + "lat": 52.3788871765137, + "lng": 4.90027761459351, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T16:34:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:35:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 60, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400057", + "uicCdCode": "118400057", + "name": "Amsterdam Amstel", + "lat": 52.3466682434082, + "lng": 4.91777801513672, + "countryCode": "NL", + "notes": [], + "routeIdx": 2, + "plannedDepartureDateTime": "2025-09-15T16:42:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T16:43:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T16:42:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T16:43:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "4", + "plannedDepartureTrack": "4", + "plannedArrivalTrack": "4", + "actualArrivalTrack": "4", + "departureDelayInSeconds": 60, + "arrivalDelayInSeconds": 60, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400621", + "uicCdCode": "118400621", + "name": "Utrecht Centraal", + "lat": 52.0888900756836, + "lng": 5.11027765274048, + "countryCode": "NL", + "notes": [], + "routeIdx": 10, + "plannedDepartureDateTime": "2025-09-15T17:03:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:03:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:00:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:00:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "15", + "plannedDepartureTrack": "15", + "plannedArrivalTrack": "15", + "actualArrivalTrack": "15", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 18, + "plannedArrivalDateTime": "2025-09-15T17:31:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:31:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "6", + "plannedDepartureTrack": "6", + "plannedArrivalTrack": "6", + "actualArrivalTrack": "6", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "MEDIUM", + "bicycleSpotCount": 6, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#1088#TA#0#DA#150925#1S#1101009#1T#1557#LS#1101011#LT#1903#PU#784#RT#1#CA#IC#ZE#2761#ZB#IC 2761 #PC#1#FR#1101009#FT#1557#TO#1101011#TT#1903#&train=2761&datetime=2025-09-15T16:34:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 57, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "56 min.", + "accessibilityValue": "56 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 84795 + }, + { + "idx": "1", + "name": "IC 3661", + "travelType": "PUBLIC_TRANSIT", + "direction": "Roosendaal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505945#TA#0#DA#150925#1S#1101167#1T#1550#LS#1101102#LT#1833#PU#784#RT#3#CA#IC#ZE#3661#ZB#IC 3661 #PC#1#FR#1101167#FT#1550#TO#1101102#TT#1833#", + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T17:41:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T17:41:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Breda", + "lng": 4.78000020980835, + "lat": 51.5955543518066, + "countryCode": "NL", + "uicCode": "8400131", + "uicCdCode": "118400131", + "stationCode": "BD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:10:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:10:00+0200", + "plannedTrack": "8", + "actualTrack": "8", + "exitSide": "LEFT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "3661", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Roosendaal", + "shortValue": "richting Roosendaal", + "accessibilityValue": "richting Roosendaal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "1 tussenstop", + "shortValue": "1 tussenstop", + "accessibilityValue": "1 tussenstop", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "Overstap op zelfde perron", + "accessibilityMessage": "Overstap op zelfde perron", + "type": "CROSS_PLATFORM", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400319", + "uicCdCode": "118400319", + "name": "'s-Hertogenbosch", + "lat": 51.69048, + "lng": 5.29362, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T17:41:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:41:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400597", + "uicCdCode": "118400597", + "name": "Tilburg", + "lat": 51.5605545043945, + "lng": 5.08361101150513, + "countryCode": "NL", + "notes": [], + "routeIdx": 1, + "plannedDepartureDateTime": "2025-09-15T17:58:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T17:58:00+0200", + "actualDepartureTimeZoneOffset": 120, + "plannedArrivalDateTime": "2025-09-15T17:56:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T17:56:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "3", + "plannedDepartureTrack": "3", + "plannedArrivalTrack": "3", + "actualArrivalTrack": "3", + "departureDelayInSeconds": 0, + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400131", + "uicCdCode": "118400131", + "name": "Breda", + "lat": 51.5955543518066, + "lng": 4.78000020980835, + "countryCode": "NL", + "notes": [], + "routeIdx": 5, + "plannedArrivalDateTime": "2025-09-15T18:10:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:10:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "8", + "plannedDepartureTrack": "8", + "plannedArrivalTrack": "8", + "actualArrivalTrack": "8", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "MEDIUM", + "punctuality": 58.3, + "crossPlatformTransfer": true, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#505945#TA#0#DA#150925#1S#1101167#1T#1550#LS#1101102#LT#1833#PU#784#RT#3#CA#IC#ZE#3661#ZB#IC 3661 #PC#1#FR#1101167#FT#1550#TO#1101102#TT#1833#&train=3661&datetime=2025-09-15T17:41:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 29, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "29 min.", + "accessibilityValue": "29 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "transferTimeToNextLeg": 2, + "distanceInMeters": 41871 + }, + { + "idx": "2", + "name": "IC 1162", + "travelType": "PUBLIC_TRANSIT", + "direction": "Den Haag Centraal", + "partCancelled": false, + "cancelled": false, + "isAfterCancelledLeg": false, + "isOnOrAfterCancelledLeg": false, + "changePossible": true, + "alternativeTransport": false, + "journeyDetailRef": "HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#51#TA#9#DA#150925#1S#1100921#1T#1743#LS#1101078#LT#1911#PU#784#RT#1#CA#IC#ZE#1162#ZB#IC 1162 #PC#1#FR#1100921#FT#1743#TO#1101078#TT#1911#", + "origin": { + "name": "Breda", + "lng": 4.78000020980835, + "lat": 51.5955543518066, + "countryCode": "NL", + "uicCode": "8400131", + "uicCdCode": "118400131", + "stationCode": "BD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:23:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:23:00+0200", + "plannedTrack": "7", + "actualTrack": "7", + "checkinStatus": "NOTHING", + "notes": [] + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION", + "plannedTimeZoneOffset": 120, + "plannedDateTime": "2025-09-15T18:45:00+0200", + "actualTimeZoneOffset": 120, + "actualDateTime": "2025-09-15T18:45:00+0200", + "plannedTrack": "13", + "actualTrack": "13", + "exitSide": "RIGHT", + "checkinStatus": "NOTHING", + "notes": [] + }, + "product": { + "productType": "Product", + "number": "1162", + "categoryCode": "IC", + "shortCategoryName": "IC", + "longCategoryName": "Intercity", + "operatorCode": "NS", + "operatorName": "NS", + "operatorAdministrativeCode": 100, + "type": "TRAIN", + "displayName": "NS Intercity", + "nameNesProperties": { + "color": "text-body" + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "notes": [ + [ + { + "value": "NS Intercity", + "shortValue": "NS Intercity", + "accessibilityValue": "NS Intercity", + "key": "PRODUCT_NAME", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "richting Den Haag Centraal", + "shortValue": "richting Den Haag Centraal", + "accessibilityValue": "richting Den Haag Centraal", + "key": "PRODUCT_DIRECTION", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ], + [ + { + "value": "Geen tussenstops", + "shortValue": "Geen tussenstops", + "accessibilityValue": "Geen tussenstops", + "key": "PRODUCT_INTERMEDIATE_STOPS", + "noteType": "ATTRIBUTE", + "isPresentationRequired": true, + "nesProperties": { + "color": "text-body" + } + } + ] + ] + }, + "transferMessages": [ + { + "message": "Overstap op zelfde perron", + "accessibilityMessage": "Overstap op zelfde perron", + "type": "CROSS_PLATFORM", + "messageNesProperties": { + "color": "text-default", + "type": "informative" + } + } + ], + "stops": [ + { + "uicCode": "8400131", + "uicCdCode": "118400131", + "name": "Breda", + "lat": 51.5955543518066, + "lng": 4.78000020980835, + "countryCode": "NL", + "notes": [], + "routeIdx": 0, + "plannedDepartureDateTime": "2025-09-15T18:23:00+0200", + "plannedDepartureTimeZoneOffset": 120, + "actualDepartureDateTime": "2025-09-15T18:23:00+0200", + "actualDepartureTimeZoneOffset": 120, + "actualDepartureTrack": "7", + "plannedDepartureTrack": "7", + "plannedArrivalTrack": "7", + "actualArrivalTrack": "7", + "departureDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + }, + { + "uicCode": "8400530", + "uicCdCode": "118400530", + "name": "Rotterdam Centraal", + "lat": 51.9249992370605, + "lng": 4.46888875961304, + "countryCode": "NL", + "notes": [], + "routeIdx": 6, + "plannedArrivalDateTime": "2025-09-15T18:45:00+0200", + "plannedArrivalTimeZoneOffset": 120, + "actualArrivalDateTime": "2025-09-15T18:45:00+0200", + "actualArrivalTimeZoneOffset": 120, + "actualDepartureTrack": "13", + "plannedDepartureTrack": "13", + "plannedArrivalTrack": "13", + "actualArrivalTrack": "13", + "arrivalDelayInSeconds": 0, + "cancelled": false, + "borderStop": false, + "passing": false + } + ], + "crowdForecast": "LOW", + "bicycleSpotCount": 16, + "punctuality": 81.8, + "shorterStock": false, + "journeyDetail": [ + { + "type": "TRAIN_XML", + "link": { + "uri": "/api/v2/journey?id=HARP_MM-2|#VN#1#ST#1757498654#PI#0#ZI#51#TA#9#DA#150925#1S#1100921#1T#1743#LS#1101078#LT#1911#PU#784#RT#1#CA#IC#ZE#1162#ZB#IC 1162 #PC#1#FR#1100921#FT#1743#TO#1101078#TT#1911#&train=1162&datetime=2025-09-15T18:23:00+02:00" + } + } + ], + "reachable": true, + "plannedDurationInMinutes": 22, + "nesProperties": { + "color": "text-info", + "scope": "LEG_LINE", + "styles": { + "type": "LineStyles", + "dashed": false + } + }, + "duration": { + "value": "22 min.", + "accessibilityValue": "22 minuten", + "nesProperties": { + "color": "text-body" + } + }, + "preSteps": [], + "postSteps": [], + "distanceInMeters": 44166 + } + ], + "checksum": "fe950328_3", + "crowdForecast": "MEDIUM", + "punctuality": 58.3, + "optimal": false, + "fares": [], + "fareLegs": [ + { + "origin": { + "name": "Amsterdam Centraal", + "lng": 4.90027761459351, + "lat": 52.3788871765137, + "countryCode": "NL", + "uicCode": "8400058", + "uicCdCode": "118400058", + "stationCode": "ASD", + "type": "STATION" + }, + "destination": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 1910, + "priceInCentsExcludingSupplement": 1910, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + }, + { + "origin": { + "name": "'s-Hertogenbosch", + "lng": 5.29362, + "lat": 51.69048, + "countryCode": "NL", + "uicCode": "8400319", + "uicCdCode": "118400319", + "stationCode": "HT", + "type": "STATION" + }, + "destination": { + "name": "Rotterdam Centraal", + "lng": 4.46888875961304, + "lat": 51.9249992370605, + "countryCode": "NL", + "uicCode": "8400530", + "uicCdCode": "118400530", + "stationCode": "RTD", + "type": "STATION" + }, + "operator": "NS", + "productTypes": ["TRAIN"], + "fares": [ + { + "priceInCents": 2010, + "priceInCentsExcludingSupplement": 2010, + "supplementInCents": 0, + "buyableTicketSupplementPriceInCents": 0, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + } + ], + "travelDate": "2025-09-15" + } + ], + "productFare": { + "priceInCents": 3920, + "priceInCentsExcludingSupplement": 3920, + "buyableTicketPriceInCents": 3920, + "buyableTicketPriceInCentsExcludingSupplement": 3920, + "product": "OVCHIPKAART_ENKELE_REIS", + "travelClass": "SECOND_CLASS", + "discountType": "NO_DISCOUNT" + }, + "fareOptions": { + "isInternationalBookable": false, + "isInternational": false, + "isEticketBuyable": false, + "isPossibleWithOvChipkaart": false, + "isTotalPriceUnknown": false, + "reasonEticketNotBuyable": { + "reason": "VIA_STATION_REQUESTED", + "description": "Je kunt voor deze reis geen kaartje kopen, omdat je je reis via een extra station hebt gepland. Uiteraard kun je voor deze reis betalen met het saldo op je OV-chipkaart." + } + }, + "nsiLink": { + "url": "https://www.nsinternational.com/nl/treintickets-v3/#/search/ASD/RTD/20250915/1634/1845?stationType=domestic&cookieConsent=false", + "showInternationalBanner": false + }, + "type": "NS", + "shareUrl": { + "uri": "https://www.ns.nl/rpx?ctx=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A34%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T18%3A45%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D1596512355" + }, + "realtime": true, + "registerJourney": { + "url": "https://treinwijzer.ns.nl/idp/login?ctxRecon=arnu%7CfromStation%3D8400058%7CrequestedFromStation%3D8400058%7CtoStation%3D8400530%7CrequestedToStation%3D8400530%7CviaStation%3D8400319%7CplannedFromTime%3D2025-09-15T16%3A34%3A00%2B02%3A00%7CplannedArrivalTime%3D2025-09-15T18%3A45%3A00%2B02%3A00%7CexcludeHighSpeedTrains%3Dfalse%7CsearchForAccessibleTrip%3Dfalse%7ClocalTrainsOnly%3Dfalse%7CdisabledTransportModalities%3DBUS%2CFERRY%2CTRAM%2CMETRO%7CtravelAssistance%3Dfalse%7CtripSummaryHash%3D1596512355&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "searchUrl": "https://treinwijzer.ns.nl/idp/login?search=true&originUicCode=8400058&destinationUicCode=8400530&dateTime=2025-09-15T16%3A28%3A00.051873%2B02%3A00&searchForArrival=false&viaUicCode=8400319&excludeHighSpeedTrains=false&localTrainsOnly=false&searchForAccessibleTrip=false&lang=nl&travelAssistance=false", + "status": "REGISTRATION_POSSIBLE", + "bicycleReservationRequired": false + }, + "modalityListItems": [ + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "4", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "7", + "accessibilityName": "Intercity" + }, + { + "name": "Intercity", + "nameNesProperties": { + "color": "text-subtle", + "styles": { + "type": "TextStyles", + "strikethrough": false, + "bold": false + } + }, + "iconNesProperties": { + "color": "text-body", + "icon": "train" + }, + "actualTrack": "7", + "accessibilityName": "Intercity" + } + ] + } + ], + "scrollRequestBackwardContext": "3|OB|MTµ14µ13954µ13944µ14077µ14080µ0µ0µ485µ13938µ1µ0µ8µ0µ0µ-2147483648µ1µ2|PDHµ28839c70675e70c3d48018993723bca2|RDµ15092025|RTµ161800|USµ0|RSµINIT", + "scrollRequestForwardContext": "3|OF|MTµ14µ13985µ13985µ14107µ14115µ0µ0µ485µ13955µ8µ0µ8µ0µ0µ-2147483648µ1µ2|PDHµ28839c70675e70c3d48018993723bca2|RDµ15092025|RTµ161800|USµ0|RSµINIT" +} diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr index 565970b953f62..998e63811614d 100644 --- a/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr @@ -1,4 +1,106 @@ # serializer version: 1 +# name: test_no_trips_sensor[sensor.to_home-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.to_home', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:train', + 'original_name': 'To home', + 'platform': 'nederlandse_spoorwegen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01K721DZPMEN39R5DK0ATBMSY9-actual_departure', + 'unit_of_measurement': None, + }) +# --- +# name: test_no_trips_sensor[sensor.to_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by NS', + 'device_class': 'timestamp', + 'friendly_name': 'To home', + 'icon': 'mdi:train', + }), + 'context': , + 'entity_id': 'sensor.to_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_no_trips_sensor[sensor.to_work-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.to_work', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:train', + 'original_name': 'To work', + 'platform': 'nederlandse_spoorwegen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01K721DZPMEN39R5DK0ATBMSY8-actual_departure', + 'unit_of_measurement': None, + }) +# --- +# name: test_no_trips_sensor[sensor.to_work-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by NS', + 'device_class': 'timestamp', + 'friendly_name': 'To work', + 'icon': 'mdi:train', + }), + 'context': , + 'entity_id': 'sensor.to_work', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[sensor.to_home-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -143,3 +245,147 @@ 'state': '2025-09-15T14:35:00+00:00', }) # --- +# name: test_single_trip_sensor[sensor.to_home-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.to_home', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:train', + 'original_name': 'To home', + 'platform': 'nederlandse_spoorwegen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01K721DZPMEN39R5DK0ATBMSY9-actual_departure', + 'unit_of_measurement': None, + }) +# --- +# name: test_single_trip_sensor[sensor.to_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'arrival_delay': False, + 'arrival_platform_actual': '13', + 'arrival_platform_planned': '13', + 'arrival_time_actual': '18:45', + 'arrival_time_planned': '18:45', + 'attribution': 'Data provided by NS', + 'departure_delay': True, + 'departure_platform_actual': '4', + 'departure_platform_planned': '4', + 'departure_time_actual': '16:35', + 'departure_time_planned': '16:34', + 'device_class': 'timestamp', + 'friendly_name': 'To home', + 'going': True, + 'icon': 'mdi:train', + 'next': None, + 'remarks': None, + 'route': list([ + 'Amsterdam Centraal', + "'s-Hertogenbosch", + 'Breda', + 'Rotterdam Centraal', + ]), + 'status': 'normal', + 'transfers': 2, + }), + 'context': , + 'entity_id': 'sensor.to_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-09-15T14:35:00+00:00', + }) +# --- +# name: test_single_trip_sensor[sensor.to_work-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.to_work', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:train', + 'original_name': 'To work', + 'platform': 'nederlandse_spoorwegen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01K721DZPMEN39R5DK0ATBMSY8-actual_departure', + 'unit_of_measurement': None, + }) +# --- +# name: test_single_trip_sensor[sensor.to_work-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'arrival_delay': False, + 'arrival_platform_actual': '13', + 'arrival_platform_planned': '13', + 'arrival_time_actual': '18:45', + 'arrival_time_planned': '18:45', + 'attribution': 'Data provided by NS', + 'departure_delay': True, + 'departure_platform_actual': '4', + 'departure_platform_planned': '4', + 'departure_time_actual': '16:35', + 'departure_time_planned': '16:34', + 'device_class': 'timestamp', + 'friendly_name': 'To work', + 'going': True, + 'icon': 'mdi:train', + 'next': None, + 'remarks': None, + 'route': list([ + 'Amsterdam Centraal', + "'s-Hertogenbosch", + 'Breda', + 'Rotterdam Centraal', + ]), + 'status': 'normal', + 'transfers': 2, + }), + 'context': , + 'entity_id': 'sensor.to_work', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-09-15T14:35:00+00:00', + }) +# --- diff --git a/tests/components/nederlandse_spoorwegen/test_sensor.py b/tests/components/nederlandse_spoorwegen/test_sensor.py index 28839f633f1c9..96110cbe0fbcc 100644 --- a/tests/components/nederlandse_spoorwegen/test_sensor.py +++ b/tests/components/nederlandse_spoorwegen/test_sensor.py @@ -68,7 +68,35 @@ async def test_config_import( @pytest.mark.freeze_time("2025-09-15 14:30:00+00:00") async def test_sensor( hass: HomeAssistant, - mock_nsapi, + mock_nsapi: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor initialization.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2025-09-15 14:30:00+00:00") +async def test_single_trip_sensor( + hass: HomeAssistant, + mock_single_trip_nsapi: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor initialization.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2025-09-15 14:30:00+00:00") +async def test_no_trips_sensor( + hass: HomeAssistant, + mock_no_trips_nsapi: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index dd6b21ab5e546..08189a125e9cb 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -2143,3 +2143,39 @@ async def test_ssdp_update_mac(hass: HomeAssistant) -> None: # ensure mac was updated with new wifiMac value assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" assert entry.unique_id == "123" + + +@pytest.mark.usefixtures("remote_websocket") +async def test_dhcp_while_user_flow_pending(hass: HomeAssistant) -> None: + """Simulate pending user flow, then trigger DHCP before submit. + + Covers https://github.com/home-assistant/core/issues/156591. + """ + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", + return_value=None, # Simulate device not connectable + ): + # Start user flow, which will show form (cannot connect) + result_user = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result_user["type"] == FlowResultType.FORM + assert result_user["step_id"] == "user" + + # While user flow is pending (form shown), trigger DHCP flow + dhcp_data = DhcpServiceInfo( + ip="10.10.12.34", macaddress="aabbccddeeff", hostname="fake_hostname" + ) + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", + return_value={ + "device": {"modelName": "fake_model", "wifiMac": "aa:bb:cc:dd:ee:ff"} + }, + ): + result_dhcp = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp_data, + ) + assert result_dhcp["type"] == FlowResultType.ABORT diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index df806df7f40c3..cf9d56557dd77 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -40,6 +40,7 @@ "cs_ka2wfrdoogpvgzfi", # https://github.com/home-assistant/core/issues/119865 "cs_qhxmvae667uap4zh", # https://github.com/home-assistant/core/issues/141278 "cs_vmxuxszzjwp5smli", # https://github.com/home-assistant/core/issues/119865 + "cs_u0wirz487erb0eka", # https://github.com/home-assistant/core/issues/155364 "cs_zibqa9dutqyaxym2", # https://github.com/home-assistant/core/pull/125098 "cwjwq_agwu93lr", # https://github.com/orgs/home-assistant/discussions/79 "cwwsq_lxfvx41gqdotrkgi", # https://github.com/orgs/home-assistant/discussions/730 diff --git a/tests/components/tuya/fixtures/cs_u0wirz487erb0eka.json b/tests/components/tuya/fixtures/cs_u0wirz487erb0eka.json new file mode 100644 index 0000000000000..0113c28e3d5d6 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_u0wirz487erb0eka.json @@ -0,0 +1,169 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "D\u00e9shumidificateur Silencieux OmniDry 20L avec Mode Linge", + "category": "cs", + "product_id": "u0wirz487erb0eka", + "product_name": "Pro Breeze 20L Compressor Dehumidifier", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-11-11T11:27:44+00:00", + "create_time": "2025-11-11T11:27:44+00:00", + "update_time": "2025-11-11T11:27:44+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 30, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "runtime_total_reset": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 30, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "humidity_indoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 20, + "max": 90, + "scale": 0, + "step": 1 + } + }, + "temp_indoor": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 5, + "max": 38, + "scale": 0, + "step": 1 + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["FULL", "Cleaning", "E1", "CL", "CH", "LO", "COIL", "MOTOR"] + } + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "filter_life": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "runtime_total_reset": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": false, + "dehumidify_set_value": 65, + "fan_speed_enum": "low", + "humidity_indoor": 72, + "temp_indoor": 16, + "anion": false, + "child_lock": false, + "countdown_set": "cancel", + "fault": 1, + "filter_reset": false, + "filter_life": 0, + "temp_unit_convert": "c", + "runtime_total_reset": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 5330e5ca7290c..310a3ad12f9eb 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -332,6 +332,63 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[fan.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge-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.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge', + '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.ake0bre784zriw0usc', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge', + 'percentage': 50, + 'percentage_step': 50.0, + 'preset_mode': None, + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[fan.hl400-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index f240c4b130dc4..13225239d0265 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -167,6 +167,63 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[humidifier.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 80, + 'min_humidity': 30, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge', + '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.ake0bre784zriw0uscswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 72, + 'device_class': 'dehumidifier', + 'friendly_name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge', + 'humidity': 65, + 'max_humidity': 80, + 'min_humidity': 30, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[humidifier.klarta_humea-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 71765b13356fb..019a795710700 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -1921,6 +1921,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[ake0bre784zriw0usc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ake0bre784zriw0usc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Pro Breeze 20L Compressor Dehumidifier', + 'model_id': 'u0wirz487erb0eka', + 'name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[ao3z3oeyvepe8o3xqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index da473187b8852..8bf6ec69311ab 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -2178,6 +2178,67 @@ 'state': 'last', }) # --- +# name: test_platform_setup_and_discovery[select.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_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.ake0bre784zriw0usccountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- # name: test_platform_setup_and_discovery[select.elivco_kitchen_socket_indicator_light_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 21778bf821974..82f70f58208e6 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -4911,6 +4911,115 @@ 'state': '121.1', }) # --- +# name: test_platform_setup_and_discovery[sensor.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_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.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_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.ake0bre784zriw0uschumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_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.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_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.ake0bre784zriw0usctemp_indoor', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.door_garage_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 1f1875080f605..2dce257feb051 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -3001,6 +3001,153 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_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.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.ake0bre784zriw0uscchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_filter_reset-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.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_filter_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:filter', + 'original_name': 'Filter reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_reset', + 'unique_id': 'tuya.ake0bre784zriw0uscfilter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_filter_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge Filter reset', + 'icon': 'mdi:filter', + }), + 'context': , + 'entity_id': 'switch.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_filter_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_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.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:atom', + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.ake0bre784zriw0uscanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge Ionizer', + 'icon': 'mdi:atom', + }), + 'context': , + 'entity_id': 'switch.deshumidificateur_silencieux_omnidry_20l_avec_mode_linge_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.din_socket-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index cb09f6c3e9fed..800edde6ba24c 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -647,14 +647,9 @@ async def test_async_get_all_descriptions( """Test async_get_all_descriptions.""" tag_trigger_descriptions = """ _: - fields: + target: entity: - selector: - entity: - filter: - domain: alarm_control_panel - supported_features: - - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME + domain: alarm_control_panel """ assert await async_setup_component(hass, DOMAIN_SUN, {}) @@ -744,22 +739,14 @@ def _load_yaml(fname, secrets=None): } }, "tag": { - "fields": { - "entity": { - "selector": { - "entity": { - "filter": [ - { - "domain": ["alarm_control_panel"], - "supported_features": [1], - } - ], - "multiple": False, - "reorder": False, - }, - }, - }, - } + "target": { + "entity": [ + { + "domain": ["alarm_control_panel"], + } + ], + }, + "fields": {}, }, } @@ -891,6 +878,5 @@ async def good_subscriber(new_triggers: set[str]): trigger.async_subscribe_platform_events(hass, good_subscriber) assert await async_setup_component(hass, "sun", {}) - assert trigger_events == [{"sun"}] assert "Error while notifying trigger platform listener" in caplog.text