diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py index 9eea047f9b7ef5..d449c9a05e8038 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from airos.airos8 import AirOS8 from homeassistant.const import ( @@ -12,10 +14,11 @@ CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, SECTION_ADVANCED_SETTINGS +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator _PLATFORMS: list[Platform] = [ @@ -23,6 +26,8 @@ Platform.SENSOR, ] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: """Set up Ubiquiti airOS from a config entry.""" @@ -54,11 +59,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: """Migrate old config entry.""" - if entry.version > 1: - # This means the user has downgraded from a future version + # This means the user has downgraded from a future version + if entry.version > 2: return False + # 1.1 Migrate config_entry to add advanced ssl settings if entry.version == 1 and entry.minor_version == 1: + new_minor_version = 2 new_data = {**entry.data} advanced_data = { CONF_SSL: DEFAULT_SSL, @@ -69,7 +76,52 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> b hass.config_entries.async_update_entry( entry, data=new_data, - minor_version=2, + minor_version=new_minor_version, + ) + + # 2.1 Migrate binary_sensor entity unique_id from device_id to mac_address + # Step 1 - migrate binary_sensor entity unique_id + # Step 2 - migrate device entity identifier + if entry.version == 1: + new_version = 2 + new_minor_version = 1 + + mac_adress = dr.format_mac(entry.unique_id) + + device_registry = dr.async_get(hass) + if device_entry := device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac_adress)} + ): + old_device_id = next( + ( + device_id + for domain, device_id in device_entry.identifiers + if domain == DOMAIN + ), + ) + + @callback + def update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + """Update unique id from device_id to mac address.""" + if old_device_id and entity_entry.unique_id.startswith(old_device_id): + suffix = entity_entry.unique_id.removeprefix(old_device_id) + new_unique_id = f"{mac_adress}{suffix}" + return {"new_unique_id": new_unique_id} + return None + + await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) + + new_identifiers = device_entry.identifiers.copy() + new_identifiers.discard((DOMAIN, old_device_id)) + new_identifiers.add((DOMAIN, mac_adress)) + device_registry.async_update_device( + device_entry.id, new_identifiers=new_identifiers + ) + + hass.config_entries.async_update_entry( + entry, version=new_version, minor_version=new_minor_version ) return True diff --git a/homeassistant/components/airos/binary_sensor.py b/homeassistant/components/airos/binary_sensor.py index 1fc89d5301ab15..994caeb2071e9f 100644 --- a/homeassistant/components/airos/binary_sensor.py +++ b/homeassistant/components/airos/binary_sensor.py @@ -98,7 +98,7 @@ def __init__( super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}" + self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}" @property def is_on(self) -> bool: diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py index fac4ccef804c53..52ad0078d16417 100644 --- a/homeassistant/components/airos/config_flow.py +++ b/homeassistant/components/airos/config_flow.py @@ -15,7 +15,12 @@ ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -57,8 +62,8 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ubiquiti airOS.""" - VERSION = 1 - MINOR_VERSION = 2 + VERSION = 2 + MINOR_VERSION = 1 def __init__(self) -> None: """Initialize the config flow.""" @@ -119,7 +124,7 @@ async def _validate_and_get_device_info( else: await self.async_set_unique_id(airos_data.derived.mac) - if self.source == SOURCE_REAUTH: + if self.source in [SOURCE_REAUTH, SOURCE_RECONFIGURE]: self._abort_if_unique_id_mismatch() else: self._abort_if_unique_id_configured() @@ -164,3 +169,54 @@ async def async_step_reauth_confirm( ), errors=self.errors, ) + + async def async_step_reconfigure( + self, + user_input: Mapping[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle reconfiguration of airOS.""" + self.errors = {} + entry = self._get_reconfigure_entry() + current_data = entry.data + + if user_input is not None: + validate_data = {**current_data, **user_input} + if await self._validate_and_get_device_info(config_data=validate_data): + return self.async_update_reload_and_abort( + entry, + data_updates=validate_data, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Required( + CONF_SSL, + default=current_data[SECTION_ADVANCED_SETTINGS][ + CONF_SSL + ], + ): bool, + vol.Required( + CONF_VERIFY_SSL, + default=current_data[SECTION_ADVANCED_SETTINGS][ + CONF_VERIFY_SSL + ], + ): bool, + } + ), + {"collapsed": True}, + ), + } + ), + errors=self.errors, + ) diff --git a/homeassistant/components/airos/entity.py b/homeassistant/components/airos/entity.py index baa1695d08e073..2a54bf2415d0c4 100644 --- a/homeassistant/components/airos/entity.py +++ b/homeassistant/components/airos/entity.py @@ -33,7 +33,7 @@ def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None: self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)}, configuration_url=configuration_url, - identifiers={(DOMAIN, str(airos_data.host.device_id))}, + identifiers={(DOMAIN, airos_data.derived.mac)}, manufacturer=MANUFACTURER, model=airos_data.host.devmodel, model_id=( diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index 8630ee8c7af8f7..46c4e287321bd8 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -10,6 +10,27 @@ "password": "[%key:component::airos::config::step::user::data_description::password%]" } }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::airos::config::step::user::data_description::password%]" + }, + "sections": { + "advanced_settings": { + "name": "[%key:component::airos::config::step::user::sections::advanced_settings::name%]", + "data": { + "ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::ssl%]", + "verify_ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::verify_ssl%]" + } + } + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -23,6 +44,7 @@ }, "sections": { "advanced_settings": { + "name": "Advanced settings", "data": { "ssl": "Use HTTPS", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" @@ -44,6 +66,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unique_id_mismatch": "Re-authentication should be used for the same device not a new one" } }, diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index ee6c3f96fc4aa7..2781812cca7eb3 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -10,8 +10,6 @@ from . import AsusWrtConfigEntry from .router import AsusWrtDevInfo, AsusWrtRouter -ATTR_LAST_TIME_REACHABLE = "last_time_reachable" - DEFAULT_DEVICE_NAME = "Unknown device" @@ -58,8 +56,6 @@ def add_entities( class AsusWrtDevice(ScannerEntity): """Representation of a AsusWrt device.""" - _unrecorded_attributes = frozenset({ATTR_LAST_TIME_REACHABLE}) - _attr_should_poll = False def __init__(self, router: AsusWrtRouter, device: AsusWrtDevInfo) -> None: @@ -97,11 +93,6 @@ def mac_address(self) -> str: def async_on_demand_update(self) -> None: """Update state.""" self._device = self._router.devices[self._device.mac] - self._attr_extra_state_attributes = {} - if self._device.last_activity: - self._attr_extra_state_attributes[ATTR_LAST_TIME_REACHABLE] = ( - self._device.last_activity.isoformat(timespec="seconds") - ) self.async_write_ha_state() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 675c2d10fea37c..a425c123e3ef59 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -136,17 +136,22 @@ async def get(self, request: web.Request) -> web.Response: url_prefix = get_url(hass, require_current_request=True) except NoURLAvailableError: url_prefix = "" - return self.json( - { - "authorization_endpoint": f"{url_prefix}/auth/authorize", - "token_endpoint": f"{url_prefix}/auth/token", - "revocation_endpoint": f"{url_prefix}/auth/revoke", - "response_types_supported": ["code"], - "service_documentation": ( - "https://developers.home-assistant.io/docs/auth_api" - ), - } - ) + + metadata = { + "authorization_endpoint": f"{url_prefix}/auth/authorize", + "token_endpoint": f"{url_prefix}/auth/token", + "revocation_endpoint": f"{url_prefix}/auth/revoke", + "response_types_supported": ["code"], + "service_documentation": ( + "https://developers.home-assistant.io/docs/auth_api" + ), + } + + # Add issuer only when we have a valid base URL (RFC 8414 compliance) + if url_prefix: + metadata["issuer"] = url_prefix + + return self.json(metadata) class AuthProvidersView(HomeAssistantView): diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index 9fe2dfb598dac7..7fb8b1450e709f 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -31,7 +31,7 @@ async def async_setup_entry( for location_id, location in coordinator.data["locations"].items() ] - async_add_entities(alarms, True) + async_add_entities(alarms) class CanaryAlarm( diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 07645f2f403d1f..2fe7e9694aedca 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -68,8 +68,7 @@ async def async_setup_entry( for location_id, location in coordinator.data["locations"].items() for device in location.devices if device.is_online - ), - True, + ) ) diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index d92166926e9c06..9643fb6805aacd 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -80,7 +80,7 @@ async def async_setup_entry( if device_type.get("name") in sensor_type[4] ) - async_add_entities(sensors, True) + async_add_entities(sensors) class CanarySensor(CoordinatorEntity[CanaryDataUpdateCoordinator], SensorEntity): diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 00acd2829a66d5..2401121b76e052 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -38,6 +38,10 @@ _LOGGER = logging.getLogger(__name__) +DESCRIPTION_PLACEHOLDER = { + "register_link": "https://electricitymaps.com/free-tier", +} + class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Co2signal.""" @@ -70,6 +74,7 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=data_schema, + description_placeholders=DESCRIPTION_PLACEHOLDER, ) data = {CONF_API_KEY: user_input[CONF_API_KEY]} @@ -179,4 +184,5 @@ async def _validate_and_create( step_id=step_id, data_schema=data_schema, errors=errors, + description_placeholders=DESCRIPTION_PLACEHOLDER, ) diff --git a/homeassistant/components/co2signal/quality_scale.yaml b/homeassistant/components/co2signal/quality_scale.yaml index d2ddb091e5ef7a..3d518092633def 100644 --- a/homeassistant/components/co2signal/quality_scale.yaml +++ b/homeassistant/components/co2signal/quality_scale.yaml @@ -18,7 +18,6 @@ rules: status: todo comment: | The config flow misses data descriptions. - Remove URLs from data descriptions, they should be replaced with placeholders. Make use of Electricity Maps zone keys in country code as dropdown. Make use of location selector for coordinates. dependency-transparency: done diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index a4ec916bd42829..69925f58993857 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -6,7 +6,7 @@ "location": "[%key:common::config_flow::data::location%]", "api_key": "[%key:common::config_flow::data::access_token%]" }, - "description": "Visit https://electricitymaps.com/free-tier to request a token." + "description": "Visit the [Electricity Maps page]({register_link}) to request a token." }, "coordinates": { "data": { diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 6aad3a81d17769..a79bd2493e1ef9 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -166,6 +166,7 @@ async def async_step_reauth_confirm( data_schema=STEP_USER_DATA_SCHEMA, description_placeholders={ "account_name": self.reauth_entry.title, + "developer_url": "https://www.coinbase.com/developer-platform", }, errors=errors, ) @@ -195,6 +196,7 @@ async def async_step_reauth_confirm( data_schema=STEP_USER_DATA_SCHEMA, description_placeholders={ "account_name": self.reauth_entry.title, + "developer_url": "https://www.coinbase.com/developer-platform", }, errors=errors, ) diff --git a/homeassistant/components/coinbase/strings.json b/homeassistant/components/coinbase/strings.json index b0774baf403d05..6c37dd5cdc49e9 100644 --- a/homeassistant/components/coinbase/strings.json +++ b/homeassistant/components/coinbase/strings.json @@ -11,7 +11,7 @@ }, "reauth_confirm": { "title": "Update Coinbase API credentials", - "description": "Your current Coinbase API key appears to be for the deprecated v2 API. Please reconfigure with a new API key created for the v3 API. Visit https://www.coinbase.com/developer-platform to create new credentials for {account_name}.", + "description": "Your current Coinbase API key appears to be for the deprecated v2 API. Please reconfigure with a new API key created for the v3 API. Visit the [Developer Platform]({developer_url}) to create new credentials for {account_name}.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "api_token": "API secret" diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index 824ce431aea1e9..7fb271d57f29a1 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -148,6 +148,15 @@ async def async_update_data() -> dict[int, dict[str, Any]]: source_type={dev_type}, idx=dev_id, name=name ) + # Skip rooms with no audio/video sources + if not sources: + _LOGGER.debug( + "Skipping room '%s' (ID: %s) - no audio/video sources found", + room.get("name"), + room_id, + ) + continue + try: hidden = room["roomHidden"] entity_list.append( diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py index ac834e92ca872e..9c9d85223614de 100644 --- a/homeassistant/components/ecobee/config_flow.py +++ b/homeassistant/components/ecobee/config_flow.py @@ -61,5 +61,8 @@ async def async_step_authorize( return self.async_show_form( step_id="authorize", errors=errors, - description_placeholders={"pin": self._ecobee.pin}, + description_placeholders={ + "pin": self._ecobee.pin, + "auth_url": "https://www.ecobee.com/consumerportal/index.html", + }, ) diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index b5cec2858111c6..7a1e4e0f8365e4 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -8,7 +8,7 @@ } }, "authorize": { - "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with PIN code:\n\n{pin}\n\nThen, select **Submit**." + "description": "Please authorize this app at {auth_url} with PIN code:\n\n{pin}\n\nThen, select **Submit**." } }, "error": { diff --git a/homeassistant/components/enphase_envoy/icons.json b/homeassistant/components/enphase_envoy/icons.json index 21262d1dc897d1..da1fdce32b4eb9 100644 --- a/homeassistant/components/enphase_envoy/icons.json +++ b/homeassistant/components/enphase_envoy/icons.json @@ -38,6 +38,25 @@ }, "available_energy": { "default": "mdi:battery-50" + }, + "grid_status": { + "default": "mdi:transmission-tower", + "state": { + "off_grid": "mdi:transmission-tower-off", + "synchronizing": "mdi:sync-alert" + } + }, + "mid_state": { + "default": "mdi:electric-switch-closed", + "state": { + "open": "mdi:electric-switch" + } + }, + "admin_state": { + "default": "mdi:transmission-tower", + "state": { + "off_grid": "mdi:transmission-tower-off" + } } }, "switch": { diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index ed3864e6f836db..807798c48cf7ce 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -824,6 +824,12 @@ class EnvoyCollarSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[EnvoyCollar], datetime.datetime | int | float | str] +# translations don't accept uppercase +ADMIN_STATE_MAP = { + "ENCMN_MDE_ON_GRID": "on_grid", + "ENCMN_MDE_OFF_GRID": "off_grid", +} + COLLAR_SENSORS = ( EnvoyCollarSensorEntityDescription( key="temperature", @@ -838,11 +844,21 @@ class EnvoyCollarSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda collar: dt_util.utc_from_timestamp(collar.last_report_date), ), + # grid_state does not seem to change when off-grid, but rather admin_state_str EnvoyCollarSensorEntityDescription( key="grid_state", translation_key="grid_status", value_fn=lambda collar: collar.grid_state, ), + # grid_status off-grid shows in admin_state rather than in grid_state + # map values as translations don't accept uppercase which these are + EnvoyCollarSensorEntityDescription( + key="admin_state_str", + translation_key="admin_state", + value_fn=lambda collar: ADMIN_STATE_MAP.get( + collar.admin_state_str, collar.admin_state_str + ), + ), EnvoyCollarSensorEntityDescription( key="mid_state", translation_key="mid_state", diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 17ed8eff67ec50..9e4b014e243503 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -409,10 +409,26 @@ "name": "Last report duration" }, "grid_status": { - "name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]" + "name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]", + "state": { + "on_grid": "On grid", + "off_grid": "Off grid", + "synchronizing": "Synchronizing to grid" + } }, "mid_state": { - "name": "MID state" + "name": "MID state", + "state": { + "open": "[%key:common::state::open%]", + "close": "[%key:common::state::closed%]" + } + }, + "admin_state": { + "name": "Admin state", + "state": { + "on_grid": "[%key:component::enphase_envoy::entity::sensor::grid_status::state::on_grid%]", + "off_grid": "[%key:component::enphase_envoy::entity::sensor::grid_status::state::off_grid%]" + } } }, "switch": { diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index fc81dfdbc43218..9fcad5aa4f1c0f 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -500,6 +500,16 @@ async def async_step_name_conflict_overwrite( ) -> ConfigFlowResult: """Handle creating a new entry by removing the old one and creating new.""" assert self._entry_with_name_conflict is not None + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + return self.async_update_reload_and_abort( + self._entry_with_name_conflict, + title=self._name, + unique_id=self.unique_id, + data=self._async_make_config_data(), + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + }, + ) await self.hass.config_entries.async_remove( self._entry_with_name_conflict.entry_id ) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index ebd354c5e8390f..72bef68b7b18b6 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -453,7 +453,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.http.app.router.register_resource(IndexView(repo_path, hass)) async_register_built_in_panel(hass, "light") - async_register_built_in_panel(hass, "security") + async_register_built_in_panel(hass, "safety") async_register_built_in_panel(hass, "climate") async_register_built_in_panel(hass, "profile") diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 002f19bc9e06a0..120d96e7d78593 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -112,6 +112,9 @@ async def _async_show_user_form( } ), errors=errors or {}, + description_placeholders={ + "sample_ip": "http://192.168.X.1", + }, ) async def _async_show_reauth_form( @@ -132,6 +135,9 @@ async def _async_show_reauth_form( } ), errors=errors or {}, + description_placeholders={ + "sample_ip": "http://192.168.X.1", + }, ) async def _connect( @@ -406,4 +412,10 @@ async def async_step_init( ): bool, } ) - return self.async_show_form(step_id="init", data_schema=data_schema) + return self.async_show_form( + step_id="init", + data_schema=data_schema, + description_placeholders={ + "sample_ip": "http://192.168.X.1", + }, + ) diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 2845338b9cfd7a..f9b968ed701625 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -41,7 +41,7 @@ }, "data_description": { "password": "Password for accessing the router's API. Typically, the same as the one used for the router's web interface.", - "url": "Base URL to the API of the router. Typically, something like `http://192.168.X.1`. This is the beginning of the location shown in a browser when accessing the router's web interface.", + "url": "Base URL to the API of the router. Typically, something like `{sample_ip}`. This is the beginning of the location shown in a browser when accessing the router's web interface.", "username": "Username for accessing the router's API. Typically, the same as the one used for the router's web interface. Usually, either `admin`, or left empty (recommended if that works).", "verify_ssl": "Whether to verify the SSL certificate of the router when accessing it. Applicable only if the router is accessed via HTTPS." }, diff --git a/homeassistant/components/igloohome/config_flow.py b/homeassistant/components/igloohome/config_flow.py index a1d84900a033af..89d072a128aa6a 100644 --- a/homeassistant/components/igloohome/config_flow.py +++ b/homeassistant/components/igloohome/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .const import API_ACCESS_URL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -57,5 +57,8 @@ async def async_step_user( ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={"api_access_url": API_ACCESS_URL}, ) diff --git a/homeassistant/components/igloohome/const.py b/homeassistant/components/igloohome/const.py index 379c3bfbc1a494..759bd6ffb5dca4 100644 --- a/homeassistant/components/igloohome/const.py +++ b/homeassistant/components/igloohome/const.py @@ -1,3 +1,4 @@ """Constants for the igloohome integration.""" DOMAIN = "igloohome" +API_ACCESS_URL = "https://access.igloocompany.co/api-access" diff --git a/homeassistant/components/igloohome/strings.json b/homeassistant/components/igloohome/strings.json index 463964c58edd8b..9a72ad1454889b 100644 --- a/homeassistant/components/igloohome/strings.json +++ b/homeassistant/components/igloohome/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Copy & paste your [API access credentials](https://access.igloocompany.co/api-access) to give Home Assistant access to your account.", + "description": "Copy & paste your [API access credentials]({api_access_url}) to give Home Assistant access to your account.", "data": { "client_id": "Client ID", "client_secret": "Client secret" diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index e3ee663e8fecfc..d258c59eb9cec3 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/intellifire", "iot_class": "local_polling", "loggers": ["intellifire4py"], - "requirements": ["intellifire4py==4.1.9"] + "requirements": ["intellifire4py==4.2.1"] } diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index 0ce92538472606..a2e9a809acbc63 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -84,9 +84,12 @@ def get_extra_attributes(self, device): (ip), reachable status (reachable), associated router (host), hostname if known (hostname) among others. """ - device = next( - (result for result in self.last_results if result.mac == device), None - ) + if not ( + device := next( + (result for result in self.last_results if result.mac == device), None + ) + ): + return {} return device._asdict() def _update_info(self): diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py index d507f91a4f3ab5..8c74e25e2feec6 100644 --- a/homeassistant/components/lunatone/__init__.py +++ b/homeassistant/components/lunatone/__init__.py @@ -45,6 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> hw_version=info_api.data.device.pcb, configuration_url=entry.data[CONF_URL], serial_number=str(info_api.serial_number), + model=info_api.product_name, model_id=( f"{info_api.data.device.article_number}{info_api.data.device.article_info}" ), diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json index 8db658869d545f..9cc7f2579ffb84 100644 --- a/homeassistant/components/lunatone/manifest.json +++ b/homeassistant/components/lunatone/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["lunatone-rest-api-client==0.4.8"] + "requirements": ["lunatone-rest-api-client==0.5.3"] } diff --git a/homeassistant/components/mealie/config_flow.py b/homeassistant/components/mealie/config_flow.py index 25e46ec6262e4a..a88347894f57c1 100644 --- a/homeassistant/components/mealie/config_flow.py +++ b/homeassistant/components/mealie/config_flow.py @@ -32,6 +32,8 @@ } ) +EXAMPLE_URL = "http://192.168.1.123:1234" + class MealieConfigFlow(ConfigFlow, domain=DOMAIN): """Mealie config flow.""" @@ -93,6 +95,7 @@ async def async_step_user( step_id="user", data_schema=USER_SCHEMA, errors=errors, + description_placeholders={"example_url": EXAMPLE_URL}, ) async def async_step_reauth( @@ -123,6 +126,7 @@ async def async_step_reauth_confirm( step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, errors=errors, + description_placeholders={"example_url": EXAMPLE_URL}, ) async def async_step_reconfigure( @@ -151,6 +155,7 @@ async def async_step_reconfigure( step_id="reconfigure", data_schema=USER_SCHEMA, errors=errors, + description_placeholders={"example_url": EXAMPLE_URL}, ) async def async_step_hassio( diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 8e51da6d7d11aa..a8a750da0ae987 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -1,6 +1,6 @@ { "common": { - "data_description_host": "The URL of your Mealie instance, for example, http://192.168.1.123:1234", + "data_description_host": "The URL of your Mealie instance, for example, {example_url}.", "data_description_api_token": "The API token of your Mealie instance from your user profile within Mealie.", "data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates." }, diff --git a/homeassistant/components/melissa/manifest.json b/homeassistant/components/melissa/manifest.json index a583c3b88fad9c..f00e0397d92ddf 100644 --- a/homeassistant/components/melissa/manifest.json +++ b/homeassistant/components/melissa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["melissa"], "quality_scale": "legacy", - "requirements": ["py-melissa-climate==2.1.4"] + "requirements": ["py-melissa-climate==3.0.2"] } diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 62105459b89130..7fb4455d546d61 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -150,6 +150,7 @@ CONF_ACTION_TOPIC, CONF_AVAILABILITY_TEMPLATE, CONF_AVAILABILITY_TOPIC, + CONF_AVAILABLE_TONES, CONF_BIRTH_MESSAGE, CONF_BLUE_TEMPLATE, CONF_BRIGHTNESS_COMMAND_TEMPLATE, @@ -307,6 +308,8 @@ CONF_STATE_VALUE_TEMPLATE, CONF_STEP, CONF_SUGGESTED_DISPLAY_PRECISION, + CONF_SUPPORT_DURATION, + CONF_SUPPORT_VOLUME_SET, CONF_SUPPORTED_COLOR_MODES, CONF_SUPPORTED_FEATURES, CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, @@ -460,6 +463,7 @@ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SIREN, Platform.SWITCH, ] @@ -1163,6 +1167,7 @@ def validate_sensor_platform_config( Platform.NOTIFY.value: None, Platform.NUMBER.value: validate_number_platform_config, Platform.SELECT: None, + Platform.SIREN: None, Platform.SENSOR.value: validate_sensor_platform_config, Platform.SWITCH.value: None, } @@ -1419,6 +1424,7 @@ class PlatformField: default=None, ), }, + Platform.SIREN: {}, Platform.SWITCH.value: { CONF_DEVICE_CLASS: PlatformField( selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False @@ -3181,6 +3187,71 @@ class PlatformField: section="advanced_settings", ), }, + Platform.SIREN: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_PAYLOAD_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_OFF, + ), + CONF_PAYLOAD_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ON, + ), + CONF_STATE_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + ), + CONF_STATE_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + ), + CONF_AVAILABLE_TONES: PlatformField( + selector=OPTIONS_SELECTOR, + required=False, + ), + CONF_SUPPORT_DURATION: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + ), + CONF_SUPPORT_VOLUME_SET: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + ), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + CONF_COMMAND_OFF_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="siren_advanced_settings", + ), + }, Platform.SWITCH.value: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index c77264b91d6241..80fbc9059461ff 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -30,6 +30,7 @@ CONF_AVAILABILITY_MODE = "availability_mode" CONF_AVAILABILITY_TEMPLATE = "availability_template" CONF_AVAILABILITY_TOPIC = "availability_topic" +CONF_AVAILABLE_TONES = "available_tones" CONF_BROKER = "broker" CONF_BIRTH_MESSAGE = "birth_message" CONF_CODE_ARM_REQUIRED = "code_arm_required" @@ -200,6 +201,8 @@ CONF_STATE_UNLOCKING = "state_unlocking" CONF_STEP = "step" CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" +CONF_SUPPORT_DURATION = "support_duration" +CONF_SUPPORT_VOLUME_SET = "support_volume_set" CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template" CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic" diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 48ab4676dea902..b4102b054a5554 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -39,10 +39,18 @@ from . import subscription from .config import MQTT_RW_SCHEMA from .const import ( + CONF_AVAILABLE_TONES, + CONF_COMMAND_OFF_TEMPLATE, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_STATE_OFF, + CONF_STATE_ON, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, + CONF_SUPPORT_DURATION, + CONF_SUPPORT_VOLUME_SET, + DEFAULT_PAYLOAD_OFF, + DEFAULT_PAYLOAD_ON, PAYLOAD_EMPTY_JSON, PAYLOAD_NONE, ) @@ -58,18 +66,9 @@ PARALLEL_UPDATES = 0 DEFAULT_NAME = "MQTT Siren" -DEFAULT_PAYLOAD_ON = "ON" -DEFAULT_PAYLOAD_OFF = "OFF" ENTITY_ID_FORMAT = siren.DOMAIN + ".{}" -CONF_AVAILABLE_TONES = "available_tones" -CONF_COMMAND_OFF_TEMPLATE = "command_off_template" -CONF_STATE_ON = "state_on" -CONF_STATE_OFF = "state_off" -CONF_SUPPORT_DURATION = "support_duration" -CONF_SUPPORT_VOLUME_SET = "support_volume_set" - STATE = "state" PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 438d64c48dfd79..a6bd64cb8734d8 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -318,6 +318,7 @@ "title": "Configure MQTT device \"{mqtt_device}\"", "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":", "data": { + "available_tones": "Available Tones", "blue_template": "Blue template", "brightness_template": "Brightness template", "code": "Alarm code", @@ -360,12 +361,15 @@ "state_topic": "State topic", "state_value_template": "State value template", "step": "Step", + "support_duration": "Duration support", + "support_volume_set": "Set volume support", "supported_color_modes": "Supported color modes", "url_template": "URL template", "url_topic": "URL topic", "value_template": "Value template" }, "data_description": { + "available_tones": "The siren supports tones. The `tone` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#available_tones)", "blue_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract blue color from the state payload value. Expected result of the template is an integer from 0-255 range.", "brightness_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract brightness from the state payload value. Expected result of the template is an integer from 0-255 range.", "code": "Specifies a code to enable or disable the alarm in the frontend. Note that this blocks sending MQTT message commands to the remote device if the code validation fails. [Learn more.]({url}#code)", @@ -407,6 +411,8 @@ "state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.", "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", "step": "Step value. Smallest value 0.001.", + "support_duration": "The siren supports setting a duration in second. The `duration` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#support_duration)", + "support_volume_set": "The siren supports setting a volume. The `tone` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#support_volume_set)", "supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)", "url_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)", "url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)", @@ -910,6 +916,15 @@ "target_humidity_state_topic": "The MQTT topic to subscribe for changes of the target humidity. [Learn more.]({url}#humidity_state_topic)" } }, + "siren_advanced_settings": { + "name": "Advanced siren settings", + "data": { + "command_off_template": "Command \"off\" template" + }, + "data_description": { + "command_off_template": "The [template]({templating_url}#using-command-templates-with-mqtt) for \"off\" state changes. By default the \"[Command template]({url}#command_template)\" will be used. [Learn more.]({url}#command_off_template)" + } + }, "target_temperature_settings": { "name": "Target temperature settings", "data": { @@ -1338,6 +1353,7 @@ "number": "[%key:component::number::title%]", "select": "[%key:component::select::title%]", "sensor": "[%key:component::sensor::title%]", + "siren": "[%key:component::siren::title%]", "switch": "[%key:component::switch::title%]" } }, diff --git a/homeassistant/components/nasweb/manifest.json b/homeassistant/components/nasweb/manifest.json index 8a4ecdbee84a70..6c447376bfeb2e 100644 --- a/homeassistant/components/nasweb/manifest.json +++ b/homeassistant/components/nasweb/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nasweb", "integration_type": "hub", "iot_class": "local_push", - "requirements": ["webio-api==0.1.11"] + "requirements": ["webio-api==0.1.12"] } diff --git a/homeassistant/components/nasweb/sensor.py b/homeassistant/components/nasweb/sensor.py index eb342d7ce92572..e01e401b2ba9d4 100644 --- a/homeassistant/components/nasweb/sensor.py +++ b/homeassistant/components/nasweb/sensor.py @@ -6,6 +6,13 @@ import time from webio_api import Input as NASwebInput, TempSensor +from webio_api.const import ( + STATE_INPUT_ACTIVE, + STATE_INPUT_NORMAL, + STATE_INPUT_PROBLEM, + STATE_INPUT_TAMPER, + STATE_INPUT_UNDEFINED, +) from homeassistant.components.sensor import ( DOMAIN as DOMAIN_SENSOR, @@ -28,11 +35,6 @@ from .const import DOMAIN, KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL SENSOR_INPUT_TRANSLATION_KEY = "sensor_input" -STATE_UNDEFINED = "undefined" -STATE_TAMPER = "tamper" -STATE_ACTIVE = "active" -STATE_NORMAL = "normal" -STATE_PROBLEM = "problem" _LOGGER = logging.getLogger(__name__) @@ -122,11 +124,11 @@ class InputStateSensor(BaseSensorEntity): _attr_device_class = SensorDeviceClass.ENUM _attr_options: list[str] = [ - STATE_UNDEFINED, - STATE_TAMPER, - STATE_ACTIVE, - STATE_NORMAL, - STATE_PROBLEM, + STATE_INPUT_ACTIVE, + STATE_INPUT_NORMAL, + STATE_INPUT_PROBLEM, + STATE_INPUT_TAMPER, + STATE_INPUT_UNDEFINED, ] _attr_translation_key = SENSOR_INPUT_TRANSLATION_KEY diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py index 740db1ed1a1cf6..06d3f57121e664 100644 --- a/homeassistant/components/nasweb/switch.py +++ b/homeassistant/components/nasweb/switch.py @@ -7,6 +7,7 @@ from typing import Any from webio_api import Output as NASwebOutput +from webio_api.const import STATE_ENTITY_UNAVAILABLE, STATE_OUTPUT_OFF, STATE_OUTPUT_ON from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity from homeassistant.core import HomeAssistant, callback @@ -25,6 +26,12 @@ OUTPUT_TRANSLATION_KEY = "switch_output" +NASWEB_STATE_TO_HA_STATE = { + STATE_ENTITY_UNAVAILABLE: None, + STATE_OUTPUT_ON: True, + STATE_OUTPUT_OFF: False, +} + _LOGGER = logging.getLogger(__name__) @@ -105,7 +112,7 @@ async def async_added_to_hass(self) -> None: @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._attr_is_on = self._output.state + self._attr_is_on = NASWEB_STATE_TO_HA_STATE[self._output.state] if ( self.coordinator.last_update is None or time.time() - self._output.last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL diff --git a/homeassistant/components/nederlandse_spoorwegen/__init__.py b/homeassistant/components/nederlandse_spoorwegen/__init__.py index 9f7177f7432686..f241f1e4cf7534 100644 --- a/homeassistant/components/nederlandse_spoorwegen/__init__.py +++ b/homeassistant/components/nederlandse_spoorwegen/__init__.py @@ -4,45 +4,39 @@ import logging -from ns_api import NSAPI, RequestParametersError -import requests - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -_LOGGER = logging.getLogger(__name__) +from .const import SUBENTRY_TYPE_ROUTE +from .coordinator import NSConfigEntry, NSDataUpdateCoordinator +_LOGGER = logging.getLogger(__name__) -type NSConfigEntry = ConfigEntry[NSAPI] PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: NSConfigEntry) -> bool: """Set up Nederlandse Spoorwegen from a config entry.""" - api_key = entry.data[CONF_API_KEY] - - client = NSAPI(api_key) - - try: - await hass.async_add_executor_job(client.get_stations) - except ( - requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - ) as error: - _LOGGER.error("Could not connect to the internet: %s", error) - raise ConfigEntryNotReady from error - except RequestParametersError as error: - _LOGGER.error("Could not fetch stations, please check configuration: %s", error) - raise ConfigEntryNotReady from error - - entry.runtime_data = client + coordinators: dict[str, NSDataUpdateCoordinator] = {} + + # Set up coordinators for all existing routes + for subentry_id, subentry in entry.subentries.items(): + if subentry.subentry_type == SUBENTRY_TYPE_ROUTE: + coordinator = NSDataUpdateCoordinator( + hass, + entry, + subentry_id, + subentry, + ) + coordinators[subentry_id] = coordinator + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinators entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True diff --git a/homeassistant/components/nederlandse_spoorwegen/config_flow.py b/homeassistant/components/nederlandse_spoorwegen/config_flow.py index f614e41a9593b1..6173d4c53ea47b 100644 --- a/homeassistant/components/nederlandse_spoorwegen/config_flow.py +++ b/homeassistant/components/nederlandse_spoorwegen/config_flow.py @@ -21,7 +21,7 @@ ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import callback from homeassistant.helpers.selector import ( SelectOptionDict, @@ -32,12 +32,12 @@ from .const import ( CONF_FROM, - CONF_NAME, CONF_ROUTES, CONF_TIME, CONF_TO, CONF_VIA, DOMAIN, + INTEGRATION_TITLE, ) _LOGGER = logging.getLogger(__name__) @@ -68,7 +68,7 @@ async def async_step_user( errors["base"] = "unknown" if not errors: return self.async_create_entry( - title="Nederlandse Spoorwegen", + title=INTEGRATION_TITLE, data={CONF_API_KEY: user_input[CONF_API_KEY]}, ) return self.async_show_form( @@ -113,7 +113,7 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu ) return self.async_create_entry( - title="Nederlandse Spoorwegen", + title=INTEGRATION_TITLE, data={CONF_API_KEY: import_data[CONF_API_KEY]}, subentries=subentries, ) diff --git a/homeassistant/components/nederlandse_spoorwegen/const.py b/homeassistant/components/nederlandse_spoorwegen/const.py index 3c350ed22ae90b..e3af02d12a0f65 100644 --- a/homeassistant/components/nederlandse_spoorwegen/const.py +++ b/homeassistant/components/nederlandse_spoorwegen/const.py @@ -1,13 +1,22 @@ """Constants for the Nederlandse Spoorwegen integration.""" +from datetime import timedelta +from zoneinfo import ZoneInfo + DOMAIN = "nederlandse_spoorwegen" +INTEGRATION_TITLE = "Nederlandse Spoorwegen" +SUBENTRY_TYPE_ROUTE = "route" +ROUTE_MODEL = "Route" +# Europe/Amsterdam timezone for Dutch rail API expectations +AMS_TZ = ZoneInfo("Europe/Amsterdam") +# Update every 2 minutes +SCAN_INTERVAL = timedelta(minutes=2) CONF_ROUTES = "routes" CONF_FROM = "from" CONF_TO = "to" CONF_VIA = "via" CONF_TIME = "time" -CONF_NAME = "name" # Attribute and schema keys ATTR_ROUTE = "route" diff --git a/homeassistant/components/nederlandse_spoorwegen/coordinator.py b/homeassistant/components/nederlandse_spoorwegen/coordinator.py new file mode 100644 index 00000000000000..0930915d69a939 --- /dev/null +++ b/homeassistant/components/nederlandse_spoorwegen/coordinator.py @@ -0,0 +1,212 @@ +"""DataUpdateCoordinator for Nederlandse Spoorwegen.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import logging + +from ns_api import NSAPI, Trip +from requests.exceptions import ConnectionError, HTTPError, Timeout + +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import ( + AMS_TZ, + CONF_FROM, + CONF_TIME, + CONF_TO, + CONF_VIA, + DOMAIN, + SCAN_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +def _now_nl() -> datetime: + """Return current time in Europe/Amsterdam timezone.""" + return dt_util.now(AMS_TZ) + + +type NSConfigEntry = ConfigEntry[dict[str, NSDataUpdateCoordinator]] + + +@dataclass +class NSRouteResult: + """Data class for Nederlandse Spoorwegen API results.""" + + trips: list[Trip] + first_trip: Trip | None = None + next_trip: Trip | None = None + + +class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]): + """Class to manage fetching Nederlandse Spoorwegen data from the API for a single route.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: NSConfigEntry, + route_id: str, + subentry: ConfigSubentry, + ) -> None: + """Initialize the coordinator for a specific route.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{route_id}", + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + self.id = route_id + self.nsapi = NSAPI(config_entry.data[CONF_API_KEY]) + self.name = subentry.data[CONF_NAME] + self.departure = subentry.data[CONF_FROM] + self.destination = subentry.data[CONF_TO] + self.via = subentry.data.get(CONF_VIA) + self.departure_time = subentry.data.get(CONF_TIME) # str | None + + async def _async_update_data(self) -> NSRouteResult: + """Fetch data from NS API for this specific route.""" + trips: list[Trip] = [] + first_trip: Trip | None = None + next_trip: Trip | None = None + try: + trips = await self._get_trips( + self.departure, + self.destination, + self.via, + departure_time=self.departure_time, + ) + + except (ConnectionError, Timeout, HTTPError, ValueError) as err: + # Surface API failures to Home Assistant so the entities become unavailable + raise UpdateFailed(f"API communication error: {err}") from err + + # Filter out trips that have already departed (trips are already sorted) + future_trips = self._remove_trips_in_the_past(trips) + + # Process trips to find current and next departure + first_trip, next_trip = self._get_first_and_next_trips(future_trips) + + return NSRouteResult( + trips=trips, + first_trip=first_trip, + next_trip=next_trip, + ) + + def _get_time_from_route(self, time_str: str | None) -> str: + """Combine today's date with a time string if needed.""" + if not time_str: + return _now_nl().strftime("%d-%m-%Y %H:%M") + + if ( + isinstance(time_str, str) + and len(time_str.split(":")) in (2, 3) + and " " not in time_str + ): + today = _now_nl().strftime("%d-%m-%Y") + return f"{today} {time_str[:5]}" + # Fallback: use current date and time + return _now_nl().strftime("%d-%m-%Y %H:%M") + + async def _get_trips( + self, + departure: str, + destination: str, + via: str | None = None, + departure_time: str | None = None, + ) -> list[Trip]: + """Get trips from NS API, sorted by departure time.""" + + # Convert time to full date-time string if needed and default to Dutch local time if not provided + time_str = self._get_time_from_route(departure_time) + + trips = await self.hass.async_add_executor_job( + self.nsapi.get_trips, + time_str, # trip_time + departure, # departure + via, # via + destination, # destination + True, # exclude_high_speed + 0, # year_card + 2, # max_number_of_transfers + ) + + if not trips: + return [] + + return sorted( + trips, + key=lambda trip: ( + trip.departure_time_actual + if trip.departure_time_actual is not None + else trip.departure_time_planned + if trip.departure_time_planned is not None + else _now_nl() + ), + ) + + def _get_first_and_next_trips( + self, trips: list[Trip] + ) -> tuple[Trip | None, Trip | None]: + """Process trips to find the first and next departure.""" + if not trips: + return None, None + + # First trip is the earliest future trip + first_trip = trips[0] + + # Find next trip with different departure time + next_trip = self._find_next_trip(trips, first_trip) + + return first_trip, next_trip + + def _remove_trips_in_the_past(self, trips: list[Trip]) -> list[Trip]: + """Filter out trips that have already departed.""" + # Compare against Dutch local time to align with ns_api timezone handling + now = _now_nl() + future_trips = [] + for trip in trips: + departure_time = ( + trip.departure_time_actual + if trip.departure_time_actual is not None + else trip.departure_time_planned + ) + if departure_time is not None and ( + departure_time.tzinfo is None + or departure_time.tzinfo.utcoffset(departure_time) is None + ): + # Make naive datetimes timezone-aware using current reference tz + departure_time = departure_time.replace(tzinfo=now.tzinfo) + + if departure_time and departure_time > now: + future_trips.append(trip) + return future_trips + + def _find_next_trip( + self, future_trips: list[Trip], first_trip: Trip + ) -> Trip | None: + """Find the next trip with a different departure time than the first trip.""" + next_trip = None + if len(future_trips) > 1: + first_time = ( + first_trip.departure_time_actual + if first_trip.departure_time_actual is not None + else first_trip.departure_time_planned + ) + for trip in future_trips[1:]: + trip_time = ( + trip.departure_time_actual + if trip.departure_time_actual is not None + else trip.departure_time_planned + ) + if trip_time and first_time and trip_time > first_time: + next_trip = trip + break + return next_trip diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index f3b6f1b28799e1..9a1ace9994aedb 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -2,13 +2,10 @@ from __future__ import annotations -import datetime as dt -from datetime import datetime, timedelta +from datetime import datetime import logging from typing import Any -from ns_api import NSAPI, Trip -import requests import voluptuous as vol from homeassistant.components.sensor import ( @@ -21,28 +18,28 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt as dt_util -from homeassistant.util.dt import parse_time - -from . import NSConfigEntry -from .const import DOMAIN +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CONF_FROM, + CONF_ROUTES, + CONF_TIME, + CONF_TO, + CONF_VIA, + DOMAIN, + INTEGRATION_TITLE, + ROUTE_MODEL, +) +from .coordinator import NSConfigEntry, NSDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -CONF_ROUTES = "routes" -CONF_FROM = "from" -CONF_TO = "to" -CONF_VIA = "via" -CONF_TIME = "time" - - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) - ROUTE_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, @@ -88,7 +85,7 @@ async def async_setup_platform( translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}", translation_placeholders={ "domain": DOMAIN, - "integration_title": "Nederlandse Spoorwegen", + "integration_title": INTEGRATION_TITLE, }, ) return @@ -104,7 +101,7 @@ async def async_setup_platform( translation_key="deprecated_yaml", translation_placeholders={ "domain": DOMAIN, - "integration_title": "Nederlandse Spoorwegen", + "integration_title": INTEGRATION_TITLE, }, ) @@ -116,34 +113,20 @@ async def async_setup_entry( ) -> None: """Set up the departure sensor from a config entry.""" - client = config_entry.runtime_data - - for subentry in config_entry.subentries.values(): - if subentry.subentry_type != "route": - continue - - async_add_entities( - [ - NSDepartureSensor( - client, - subentry.data[CONF_NAME], - subentry.data[CONF_FROM], - subentry.data[CONF_TO], - subentry.subentry_id, - subentry.data.get(CONF_VIA), - ( - parse_time(subentry.data[CONF_TIME]) - if CONF_TIME in subentry.data - else None - ), - ) - ], - config_subentry_id=subentry.subentry_id, - update_before_add=True, + coordinators = config_entry.runtime_data + + for subentry_id, coordinator in coordinators.items(): + # Build entity from coordinator fields directly + entity = NSDepartureSensor( + subentry_id, + coordinator, ) + # Add entity with proper subentry association + async_add_entities([entity], config_subentry_id=subentry_id) -class NSDepartureSensor(SensorEntity): + +class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity): """Implementation of a NS Departure Sensor.""" _attr_device_class = SensorDeviceClass.TIMESTAMP @@ -152,71 +135,86 @@ class NSDepartureSensor(SensorEntity): def __init__( self, - nsapi: NSAPI, - name: str, - departure: str, - heading: str, subentry_id: str, - via: str | None, - time: dt.time | None, + coordinator: NSDataUpdateCoordinator, ) -> None: """Initialize the sensor.""" - self._nsapi = nsapi - self._name = name - self._departure = departure - self._via = via - self._heading = heading - self._time = time - self._trips: list[Trip] | None = None - self._first_trip: Trip | None = None - self._next_trip: Trip | None = None + super().__init__(coordinator) + self._name = coordinator.name + self._subentry_id = subentry_id self._attr_unique_id = f"{subentry_id}-actual_departure" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._subentry_id)}, + name=self._name, + manufacturer=INTEGRATION_TITLE, + model=ROUTE_MODEL, + ) @property def name(self) -> str: """Return the name of the sensor.""" return self._name + @property + def native_value(self) -> datetime | None: + """Return the native value of the sensor.""" + route_data = self.coordinator.data + if not route_data.first_trip: + 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 + @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - if not self._trips or self._first_trip is None: + route_data = self.coordinator.data + if not route_data: return None - if self._first_trip.trip_parts: - route = [self._first_trip.departure] - route.extend(k.destination for k in self._first_trip.trip_parts) + first_trip = route_data.first_trip + next_trip = route_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 = { - "going": self._first_trip.going, + "going": first_trip.going, "departure_time_planned": None, "departure_time_actual": None, "departure_delay": False, - "departure_platform_planned": self._first_trip.departure_platform_planned, - "departure_platform_actual": self._first_trip.departure_platform_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_platform_planned": self._first_trip.arrival_platform_planned, - "arrival_platform_actual": self._first_trip.arrival_platform_actual, + "arrival_platform_planned": first_trip.arrival_platform_planned, + "arrival_platform_actual": first_trip.arrival_platform_actual, "next": None, - "status": self._first_trip.status.lower(), - "transfers": self._first_trip.nr_transfers, + "status": first_trip.status.lower() if first_trip.status else None, + "transfers": first_trip.nr_transfers, "route": route, "remarks": None, } # Planned departure attributes - if self._first_trip.departure_time_planned is not None: + if first_trip.departure_time_planned is not None: attributes["departure_time_planned"] = ( - self._first_trip.departure_time_planned.strftime("%H:%M") + first_trip.departure_time_planned.strftime("%H:%M") ) # Actual departure attributes - if self._first_trip.departure_time_actual is not None: + if first_trip.departure_time_actual is not None: attributes["departure_time_actual"] = ( - self._first_trip.departure_time_actual.strftime("%H:%M") + first_trip.departure_time_actual.strftime("%H:%M") ) # Delay departure attributes @@ -229,15 +227,15 @@ def extra_state_attributes(self) -> dict[str, Any] | None: attributes["departure_delay"] = True # Planned arrival attributes - if self._first_trip.arrival_time_planned is not None: + if first_trip.arrival_time_planned is not None: attributes["arrival_time_planned"] = ( - self._first_trip.arrival_time_planned.strftime("%H:%M") + first_trip.arrival_time_planned.strftime("%H:%M") ) # Actual arrival attributes - if self._first_trip.arrival_time_actual is not None: - attributes["arrival_time_actual"] = ( - self._first_trip.arrival_time_actual.strftime("%H:%M") + if first_trip.arrival_time_actual is not None: + attributes["arrival_time_actual"] = first_trip.arrival_time_actual.strftime( + "%H:%M" ) # Delay arrival attributes @@ -248,89 +246,11 @@ def extra_state_attributes(self) -> dict[str, Any] | None: ): attributes["arrival_delay"] = True - assert self._next_trip is not None - # Next attributes - if self._next_trip.departure_time_actual is not None: - attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M") - elif self._next_trip.departure_time_planned is not None: - attributes["next"] = self._next_trip.departure_time_planned.strftime( - "%H:%M" - ) - else: - attributes["next"] = None + # 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 - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self) -> None: - """Get the trip information.""" - - # If looking for a specific trip time, update around that trip time only. - if self._time and ( - (datetime.now() + timedelta(minutes=30)).time() < self._time - or (datetime.now() - timedelta(minutes=30)).time() > self._time - ): - self._attr_native_value = None - self._trips = None - self._first_trip = None - return - - # Set the search parameter to search from a specific trip time - # or to just search for next trip. - if self._time: - trip_time = ( - datetime.today() - .replace(hour=self._time.hour, minute=self._time.minute) - .strftime("%d-%m-%Y %H:%M") - ) - else: - trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M") - - try: - self._trips = self._nsapi.get_trips( - trip_time, self._departure, self._via, self._heading, True, 0, 2 - ) - if self._trips: - all_times = [] - - # If a train is delayed we can observe this through departure_time_actual. - for trip in self._trips: - if trip.departure_time_actual is None: - all_times.append(trip.departure_time_planned) - else: - all_times.append(trip.departure_time_actual) - - # Remove all trains that already left. - filtered_times = [ - (i, time) - for i, time in enumerate(all_times) - if time > dt_util.now() - ] - - if len(filtered_times) > 0: - sorted_times = sorted(filtered_times, key=lambda x: x[1]) - self._first_trip = self._trips[sorted_times[0][0]] - self._attr_native_value = sorted_times[0][1] - - # Filter again to remove trains that leave at the exact same time. - filtered_times = [ - (i, time) - for i, time in enumerate(all_times) - if time > sorted_times[0][1] - ] - - if len(filtered_times) > 0: - sorted_times = sorted(filtered_times, key=lambda x: x[1]) - self._next_trip = self._trips[sorted_times[0][0]] - else: - self._next_trip = None - - else: - self._first_trip = None - self._attr_native_value = None - - except ( - requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - ) as error: - _LOGGER.error("Couldn't fetch trip info: %s", error) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 9824d736fe9621..9df044ebc8bbad 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -330,7 +330,7 @@ class NumberDeviceClass(StrEnum): Unit of measurement: - `mbar`, `cbar`, `bar` - - `Pa`, `hPa`, `kPa` + - `mPa`, `Pa`, `hPa`, `kPa` - `inHg` - `psi` - `inH₂O` diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 396539d93e3b67..72c520d5a7f88c 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -2,7 +2,7 @@ import logging -from pyownet import protocol +from aio_ownet.exceptions import OWServerConnectionError, OWServerReturnError from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -28,8 +28,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> b try: await onewire_hub.initialize() except ( - protocol.ConnError, # Failed to connect to the server - protocol.OwnetError, # Connected to server, but failed to list the devices + OWServerConnectionError, # Failed to connect to the server + OWServerReturnError, # Connected to server, but failed to list the devices ) as exc: raise ConfigEntryNotReady from exc diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 0f2a2b6c51c32c..f10692061ae9fe 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -5,7 +5,8 @@ from copy import deepcopy from typing import Any -from pyownet import protocol +from aio_ownet.exceptions import OWServerConnectionError +from aio_ownet.proxy import OWServerStatelessProxy import voluptuous as vol from homeassistant.config_entries import ( @@ -45,11 +46,10 @@ async def validate_input( hass: HomeAssistant, data: dict[str, Any], errors: dict[str, str] ) -> None: """Validate the user input allows us to connect.""" + proxy = OWServerStatelessProxy(data[CONF_HOST], data[CONF_PORT]) try: - await hass.async_add_executor_job( - protocol.proxy, data[CONF_HOST], data[CONF_PORT] - ) - except protocol.ConnError: + await proxy.validate() + except OWServerConnectionError: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index c66ec3bef15e5b..9adc046acc1fe8 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -6,7 +6,8 @@ import logging from typing import Any -from pyownet import protocol +from aio_ownet.exceptions import OWServerError +from aio_ownet.proxy import OWServerStatelessProxy from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription @@ -36,7 +37,7 @@ def __init__( device_id: str, device_info: DeviceInfo, device_file: str, - owproxy: protocol._Proxy, + owproxy: OWServerStatelessProxy, ) -> None: """Initialize the entity.""" self.entity_description = description @@ -55,20 +56,19 @@ def extra_state_attributes(self) -> dict[str, Any] | None: "device_file": self._device_file, } - def _read_value(self) -> str: + async def _read_value(self) -> str: """Read a value from the server.""" - read_bytes: bytes = self._owproxy.read(self._device_file) - return read_bytes.decode().lstrip() + return (await self._owproxy.read(self._device_file)).decode().lstrip() - def _write_value(self, value: bytes) -> None: + async def _write_value(self, value: bytes) -> None: """Write a value to the server.""" - self._owproxy.write(self._device_file, value) + await self._owproxy.write(self._device_file, value) - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data from the device.""" try: - self._value_raw = float(self._read_value()) - except protocol.Error as exc: + self._value_raw = float(await self._read_value()) + except OWServerError as exc: if self._last_update_success: _LOGGER.error("Error fetching %s data: %s", self.name, exc) self._last_update_success = False diff --git a/homeassistant/components/onewire/manifest.json b/homeassistant/components/onewire/manifest.json index 844c4c1afb9935..80d3a6fdc3c937 100644 --- a/homeassistant/components/onewire/manifest.json +++ b/homeassistant/components/onewire/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/onewire", "integration_type": "hub", "iot_class": "local_polling", - "loggers": ["pyownet"], - "requirements": ["pyownet==0.10.0.post1"], + "loggers": ["aio_ownet"], + "requirements": ["aio-ownet==0.0.3"], "zeroconf": ["_owserver._tcp.local."] } diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index dc894a4242e1a1..b03b2fa3c683ad 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -7,7 +7,9 @@ import logging import os -from pyownet import protocol +from aio_ownet.definitions import OWServerCommonPath +from aio_ownet.exceptions import OWServerProtocolError, OWServerReturnError +from aio_ownet.proxy import OWServerStatelessProxy from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VIA_DEVICE, CONF_HOST, CONF_PORT @@ -57,7 +59,7 @@ def _is_known_device(device_family: str, device_type: str | None) -> bool: class OneWireHub: """Hub to communicate with server.""" - owproxy: protocol._Proxy + owproxy: OWServerStatelessProxy devices: list[OWDeviceDescription] _version: str | None = None @@ -66,23 +68,25 @@ def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> Non self._hass = hass self._config_entry = config_entry - def _initialize(self) -> None: - """Connect to the server, and discover connected devices. - - Needs to be run in executor. - """ + async def _initialize(self) -> None: + """Connect to the server, and discover connected devices.""" host = self._config_entry.data[CONF_HOST] port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) - self.owproxy = protocol.proxy(host, port) - with contextlib.suppress(protocol.OwnetError): + self.owproxy = OWServerStatelessProxy( + self._config_entry.data[CONF_HOST], self._config_entry.data[CONF_PORT] + ) + await self.owproxy.validate() + with contextlib.suppress(OWServerReturnError): # Version is not available on all servers - self._version = self.owproxy.read(protocol.PTH_VERSION).decode() - self.devices = _discover_devices(self.owproxy) + self._version = ( + await self.owproxy.read(OWServerCommonPath.VERSION) + ).decode() + self.devices = await _discover_devices(self.owproxy) async def initialize(self) -> None: """Initialize a config entry.""" - await self._hass.async_add_executor_job(self._initialize) + await self._initialize() self._populate_device_registry(self.devices) @callback @@ -106,9 +110,7 @@ def schedule_scan_for_new_devices(self) -> None: async def _scan_for_new_devices(self, _: datetime) -> None: """Scan the bus for new devices.""" - devices = await self._hass.async_add_executor_job( - _discover_devices, self.owproxy - ) + devices = await _discover_devices(self.owproxy) existing_device_ids = [device.id for device in self.devices] new_devices = [ device for device in devices if device.id not in existing_device_ids @@ -121,16 +123,16 @@ async def _scan_for_new_devices(self, _: datetime) -> None: ) -def _discover_devices( - owproxy: protocol._Proxy, path: str = "/", parent_id: str | None = None +async def _discover_devices( + owproxy: OWServerStatelessProxy, path: str = "/", parent_id: str | None = None ) -> list[OWDeviceDescription]: """Discover all server devices.""" devices: list[OWDeviceDescription] = [] - for device_path in owproxy.dir(path): + for device_path in await owproxy.dir(path): device_id = os.path.split(os.path.split(device_path)[0])[1] - device_family = owproxy.read(f"{device_path}family").decode() + device_family = (await owproxy.read(f"{device_path}family")).decode() _LOGGER.debug("read `%sfamily`: %s", device_path, device_family) - device_type = _get_device_type(owproxy, device_path) + device_type = await _get_device_type(owproxy, device_path) if not _is_known_device(device_family, device_type): _LOGGER.warning( "Ignoring unknown device family/type (%s/%s) found for device %s", @@ -159,22 +161,24 @@ def _discover_devices( devices.append(device) if device_branches := DEVICE_COUPLERS.get(device_family): for branch in device_branches: - devices += _discover_devices( + devices += await _discover_devices( owproxy, f"{device_path}{branch}", device_id ) return devices -def _get_device_type(owproxy: protocol._Proxy, device_path: str) -> str | None: +async def _get_device_type( + owproxy: OWServerStatelessProxy, device_path: str +) -> str | None: """Get device model.""" try: - device_type: str = owproxy.read(f"{device_path}type").decode() - except protocol.ProtocolError as exc: + device_type = (await owproxy.read(f"{device_path}type")).decode() + except OWServerProtocolError as exc: _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) return None _LOGGER.debug("read `%stype`: %s", device_path, device_type) if device_type == "EDS": - device_type = owproxy.read(f"{device_path}device_type").decode() + device_type = (await owproxy.read(f"{device_path}device_type")).decode() _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type) return device_type diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py index 7f4111243aa780..7a33471dbe5a4b 100644 --- a/homeassistant/components/onewire/select.py +++ b/homeassistant/components/onewire/select.py @@ -105,6 +105,6 @@ def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return str(self._state) - def select_option(self, option: str) -> None: + async def async_select_option(self, option: str) -> None: """Change the selected option.""" - self._write_value(option.encode("ascii")) + await self._write_value(option.encode("ascii")) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 7039dc098580ba..ee0a3cbacbbfb3 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -9,7 +9,7 @@ import os from typing import Any -from pyownet import protocol +from aio_ownet.exceptions import OWServerReturnError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -397,11 +397,7 @@ async def _add_entities( """Add 1-Wire entities for all devices.""" if not devices: return - # note: we have to go through the executor as SENSOR platform - # makes extra calls to the hub during device listing - entities = await hass.async_add_executor_job( - get_entities, hub, devices, config_entry.options - ) + entities = await get_entities(hub, devices, config_entry.options) async_add_entities(entities, True) hub = config_entry.runtime_data @@ -411,7 +407,7 @@ async def _add_entities( ) -def get_entities( +async def get_entities( onewire_hub: OneWireHub, devices: list[OWDeviceDescription], options: Mapping[str, Any], @@ -441,8 +437,10 @@ def get_entities( if description.key.startswith("moisture/"): s_id = description.key.split(".")[1] is_leaf = int( - onewire_hub.owproxy.read( - f"{device_path}moisture/is_leaf.{s_id}" + ( + await onewire_hub.owproxy.read( + f"{device_path}moisture/is_leaf.{s_id}" + ) ).decode() ) if is_leaf: @@ -463,8 +461,8 @@ def get_entities( if family == "12": # We need to check if there is TAI8570 plugged in try: - onewire_hub.owproxy.read(device_file) - except protocol.OwnetError as err: + await onewire_hub.owproxy.read(device_file) + except OWServerReturnError as err: _LOGGER.debug( "Ignoring unreachable sensor %s", device_file, diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index aeea0b8e98b760..23f85714136d82 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -220,10 +220,10 @@ def is_on(self) -> bool | None: return None return self._state == 1 - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - self._write_value(b"1") + await self._write_value(b"1") - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self._write_value(b"0") + await self._write_value(b"0") diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index b0a15b3970ee66..dbdf833314625c 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -120,7 +120,7 @@ class OverkizSensorDescription(SensorEntityDescription): icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.WATER, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), OverkizSensorDescription( key=OverkizState.IO_OUTLET_ENGINE, diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 1a1481f9c264f6..4a071921938188 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -72,6 +72,7 @@ ), } ) +PLACEHOLDER = {"example_url": "https://example.com:8000/path"} async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> None: @@ -134,6 +135,7 @@ async def async_step_user( STEP_USER_DATA_SCHEMA, user_input ), errors=errors, + description_placeholders=PLACEHOLDER, ) async def async_step_reauth( @@ -211,7 +213,10 @@ async def async_step_reconfigure( STEP_USER_DATA_SCHEMA, suggested_values, ), - description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, + description_placeholders={ + CONF_NAME: reconfig_entry.data[CONF_USERNAME], + **PLACEHOLDER, + }, errors=errors, ) diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 66435fd2806185..05e5c47fee4cf0 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -9,7 +9,7 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "url": "Specify the full URL of your pyLoad web interface, including the protocol (HTTP or HTTPS), hostname or IP address, port (pyLoad uses 8000 by default), and any path prefix if applicable.\nExample: `https://example.com:8000/path`", + "url": "Specify the full URL of your pyLoad web interface, including the protocol (HTTP or HTTPS), hostname or IP address, port (pyLoad uses 8000 by default), and any path prefix if applicable.\nExample: `{example_url}`", "username": "The username used to access the pyLoad instance.", "password": "The password associated with the pyLoad account.", "verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection." diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index a0f5c779c0eef1..4f1a9a0d878435 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -78,13 +78,7 @@ StatisticsShortTerm, ) from .executor import DBInterruptibleThreadPoolExecutor -from .models import ( - DatabaseEngine, - StatisticData, - StatisticMeanType, - StatisticMetaData, - UnsupportedDialect, -) +from .models import DatabaseEngine, StatisticData, StatisticMetaData, UnsupportedDialect from .pool import POOL_SIZE, MutexPool, RecorderPool from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager @@ -621,17 +615,6 @@ def async_import_statistics( table: type[Statistics | StatisticsShortTerm], ) -> None: """Schedule import of statistics.""" - if "mean_type" not in metadata: - # Backwards compatibility for old metadata format - # Can be removed after 2026.4 - metadata["mean_type"] = ( # type: ignore[unreachable] - StatisticMeanType.ARITHMETIC - if metadata.get("has_mean") - else StatisticMeanType.NONE - ) - # Remove deprecated has_mean as it's not needed anymore in core - metadata.pop("has_mean", None) - self.queue_task(ImportStatisticsTask(metadata, stats, table)) @callback diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 0c504d106ef275..647053fe9c2f4c 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2595,6 +2595,13 @@ def _async_import_statistics( statistics: Iterable[StatisticData], ) -> None: """Validate timestamps and insert an import_statistics job in the queue.""" + if "mean_type" not in metadata: + metadata["mean_type"] = ( # type: ignore[unreachable] + StatisticMeanType.ARITHMETIC + if metadata.pop("has_mean", False) + else StatisticMeanType.NONE + ) + # If unit class is not set, we try to set it based on the unit of measurement # Note: This can't happen from the type checker's perspective, but we need # to guard against custom integrations that have not been updated to set @@ -2661,6 +2668,12 @@ def async_import_statistics( if not metadata["source"] or metadata["source"] != DOMAIN: raise HomeAssistantError("Invalid source") + if "mean_type" not in metadata and not _called_from_ws_api: # type: ignore[unreachable] + report_usage( # type: ignore[unreachable] + "doesn't specify mean_type when calling async_import_statistics", + breaks_in_ha_version="2026.11", + exclude_integrations={DOMAIN}, + ) if "unit_class" not in metadata and not _called_from_ws_api: # type: ignore[unreachable] report_usage( # type: ignore[unreachable] "doesn't specify unit_class when calling async_import_statistics", @@ -2692,6 +2705,12 @@ def async_add_external_statistics( if not metadata["source"] or metadata["source"] != domain: raise HomeAssistantError("Invalid source") + if "mean_type" not in metadata and not _called_from_ws_api: # type: ignore[unreachable] + report_usage( # type: ignore[unreachable] + "doesn't specify mean_type when calling async_import_statistics", + breaks_in_ha_version="2026.11", + exclude_integrations={DOMAIN}, + ) if "unit_class" not in metadata and not _called_from_ws_api: # type: ignore[unreachable] report_usage( # type: ignore[unreachable] "doesn't specify unit_class when calling async_add_external_statistics", diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index a4a7db15a7ce17..6aa8c44ad4a12e 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -544,7 +544,11 @@ def valid_units( { vol.Required("type"): "recorder/import_statistics", vol.Required("metadata"): { - vol.Required("has_mean"): bool, + vol.Optional("has_mean"): bool, + vol.Optional("mean_type"): vol.All( + vol.In(StatisticMeanType.__members__.values()), + vol.Coerce(StatisticMeanType), + ), vol.Required("has_sum"): bool, vol.Required("name"): vol.Any(str, None), vol.Required("source"): str, @@ -574,10 +578,12 @@ def ws_import_statistics( The unit_class specifies which unit conversion class to use, if applicable. """ metadata = msg["metadata"] - # The WS command will be changed in a follow up PR - metadata["mean_type"] = ( - StatisticMeanType.ARITHMETIC if metadata["has_mean"] else StatisticMeanType.NONE - ) + if "mean_type" not in metadata: + _LOGGER.warning( + "WS command recorder/import_statistics called without specifying " + "mean_type in metadata, this is deprecated and will stop working " + "in HA Core 2026.11" + ) if "unit_class" not in metadata: _LOGGER.warning( "WS command recorder/import_statistics called without specifying " diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 89059e890f47c6..6ae9693887e8e5 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -260,4 +260,9 @@ async def set_charge_schedules( key="res_state", update_method=lambda x: x.get_res_state, ), + RenaultCoordinatorDescription( + endpoint="pressure", + key="pressure", + update_method=lambda x: x.get_tyre_pressure, + ), ) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 7c513c1b9def52..e3eefde1aa748e 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -13,6 +13,7 @@ KamereonVehicleHvacStatusData, KamereonVehicleLocationData, KamereonVehicleResStateData, + KamereonVehicleTyrePressureData, ) from homeassistant.components.sensor import ( @@ -26,6 +27,7 @@ UnitOfEnergy, UnitOfLength, UnitOfPower, + UnitOfPressure, UnitOfTemperature, UnitOfTime, UnitOfVolume, @@ -337,4 +339,44 @@ def _get_utc_value(entity: RenaultSensor[T]) -> datetime: entity_registry_enabled_default=False, translation_key="res_state_code", ), + RenaultSensorEntityDescription( + key="front_left_pressure", + coordinator="pressure", + data_key="flPressure", + device_class=SensorDeviceClass.PRESSURE, + entity_class=RenaultSensor[KamereonVehicleTyrePressureData], + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + translation_key="front_left_pressure", + ), + RenaultSensorEntityDescription( + key="front_right_pressure", + coordinator="pressure", + data_key="frPressure", + device_class=SensorDeviceClass.PRESSURE, + entity_class=RenaultSensor[KamereonVehicleTyrePressureData], + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + translation_key="front_right_pressure", + ), + RenaultSensorEntityDescription( + key="rear_left_pressure", + coordinator="pressure", + data_key="rlPressure", + device_class=SensorDeviceClass.PRESSURE, + entity_class=RenaultSensor[KamereonVehicleTyrePressureData], + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + translation_key="rear_left_pressure", + ), + RenaultSensorEntityDescription( + key="rear_right_pressure", + coordinator="pressure", + data_key="rrPressure", + device_class=SensorDeviceClass.PRESSURE, + entity_class=RenaultSensor[KamereonVehicleTyrePressureData], + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + translation_key="rear_right_pressure", + ), ) diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index dabe2f77bac63f..851a761fc8b539 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -166,6 +166,18 @@ }, "res_state_code": { "name": "Remote engine start code" + }, + "front_left_pressure": { + "name": "Front left tyre pressure" + }, + "front_right_pressure": { + "name": "Front right tyre pressure" + }, + "rear_left_pressure": { + "name": "Rear left tyre pressure" + }, + "rear_right_pressure": { + "name": "Rear right tyre pressure" } } }, diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py index ce9b0a13b18dae..c7d299c825b245 100644 --- a/homeassistant/components/sabnzbd/config_flow.py +++ b/homeassistant/components/sabnzbd/config_flow.py @@ -90,4 +90,8 @@ async def async_step_user( else user_input, ), errors=errors, + description_placeholders={ + "sabnzbd_full_url_local": "http://localhost:8080", + "sabnzbd_full_url_addon": "http://a02368d7-sabnzbd:8080", + }, ) diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 601f1153b82317..e9c5b6884ab2ab 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -7,7 +7,7 @@ "url": "[%key:common::config_flow::data::url%]" }, "data_description": { - "url": "The full URL, including port, of the SABnzbd server. Example: `http://localhost:8080` or `http://a02368d7-sabnzbd:8080`, if you are using the add-on.", + "url": "The full URL, including port, of the SABnzbd server. Example: `{sabnzbd_full_url_local}` or `{sabnzbd_full_url_addon}`, if you are using the add-on.", "api_key": "The API key of the SABnzbd server. This can be found in the SABnzbd web interface under Config cog (top right) > General > Security." } } diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 356d2313c31002..86af837e2b3cac 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -365,7 +365,7 @@ class SensorDeviceClass(StrEnum): Unit of measurement: - `mbar`, `cbar`, `bar` - - `Pa`, `hPa`, `kPa` + - `mPa`, `Pa`, `hPa`, `kPa` - `inHg` - `psi` - `inH₂O` diff --git a/homeassistant/components/sfr_box/config_flow.py b/homeassistant/components/sfr_box/config_flow.py index 629f6ad291fa44..186bccbd93ba9e 100644 --- a/homeassistant/components/sfr_box/config_flow.py +++ b/homeassistant/components/sfr_box/config_flow.py @@ -61,7 +61,13 @@ async def async_step_user( data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors + step_id="user", + data_schema=data_schema, + errors=errors, + description_placeholders={ + "sample_ip": "192.168.1.1", + "sample_url": "https://sfrbox.example.com", + }, ) async def async_step_choose_auth( diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 5139ec52badd9c..47f4b6a7bf319d 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -27,7 +27,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname, IP address, or full URL of your SFR device. e.g.: '192.168.1.1' or 'https://sfrbox.example.com'" + "host": "The hostname, IP address, or full URL of your SFR device. e.g.: `{sample_ip}` or `{sample_url}`" }, "description": "Setting the credentials is optional, but enables additional functionality." } diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index b56a2b103ac93a..40f75230c59e20 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -29,6 +29,7 @@ ShellyRpcAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rpc, + rpc_call, ) from .utils import get_device_entry_gen @@ -192,6 +193,7 @@ class RpcShellyCover(ShellyRpcAttributeEntity, CoverEntity): _attr_supported_features: CoverEntityFeature = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP ) + _id: int def __init__( self, @@ -260,7 +262,7 @@ async def update_position(self) -> None: """Update the cover position every second.""" try: while self.is_closing or self.is_opening: - await self.coordinator.device.update_status() + await self.coordinator.device.update_cover_status(self._id) self.async_write_ha_state() await asyncio.sleep(RPC_COVER_UPDATE_TIME_SEC) finally: @@ -274,39 +276,46 @@ def _update_callback(self) -> None: if self.is_closing or self.is_opening: self.launch_update_task() + @rpc_call async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self.call_rpc("Cover.Close", {"id": self._id}) + await self.coordinator.device.cover_close(self._id) + @rpc_call async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" - await self.call_rpc("Cover.Open", {"id": self._id}) + await self.coordinator.device.cover_open(self._id) + @rpc_call async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - await self.call_rpc( - "Cover.GoToPosition", {"id": self._id, "pos": kwargs[ATTR_POSITION]} + await self.coordinator.device.cover_set_position( + self._id, pos=kwargs[ATTR_POSITION] ) + @rpc_call async def async_stop_cover(self, **_kwargs: Any) -> None: """Stop the cover.""" - await self.call_rpc("Cover.Stop", {"id": self._id}) + await self.coordinator.device.cover_stop(self._id) + @rpc_call async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - await self.call_rpc("Cover.GoToPosition", {"id": self._id, "slat_pos": 100}) + await self.coordinator.device.cover_set_position(self._id, slat_pos=100) + @rpc_call async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - await self.call_rpc("Cover.GoToPosition", {"id": self._id, "slat_pos": 0}) + await self.coordinator.device.cover_set_position(self._id, slat_pos=0) + @rpc_call async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - await self.call_rpc( - "Cover.GoToPosition", - {"id": self._id, "slat_pos": kwargs[ATTR_TILT_POSITION]}, + await self.coordinator.device.cover_set_position( + self._id, slat_pos=kwargs[ATTR_TILT_POSITION] ) + @rpc_call async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover.""" - await self.call_rpc("Cover.Stop", {"id": self._id}) + await self.coordinator.device.cover_stop(self._id) diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 0336ede3eeac96..47147f21f4036b 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -10,6 +10,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/squeezebox", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pysqueezebox"], "requirements": ["pysqueezebox==0.13.0"] diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index ff15b980d5e71b..87f0db93030ee7 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -38,6 +38,7 @@ SENSOR_TYPE_POWER = "power" SENSOR_TYPE_VOLTAGE = "voltage" SENSOR_TYPE_CURRENT = "electricCurrent" +SENSOR_TYPE_POWER_CONSUMPTION = "weight" SENSOR_TYPE_USED_ELECTRICITY = "usedElectricity" SENSOR_TYPE_LIGHTLEVEL = "lightLevel" @@ -120,6 +121,13 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ) +POWER_CONSUMPTION_DESCRIPTION = SensorEntityDescription( + key=SENSOR_TYPE_POWER_CONSUMPTION, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, +) + RELAY_SWITCH_2PM_POWER_DESCRIPTION = SensorEntityDescription( key=RELAY_SWITCH_2PM_SENSOR_TYPE_POWER, device_class=SensorDeviceClass.POWER, @@ -180,10 +188,12 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): "Plug Mini (US)": ( VOLTAGE_DESCRIPTION, CURRENT_DESCRIPTION_IN_MA, + POWER_CONSUMPTION_DESCRIPTION, ), "Plug Mini (JP)": ( VOLTAGE_DESCRIPTION, CURRENT_DESCRIPTION_IN_MA, + POWER_CONSUMPTION_DESCRIPTION, ), "Plug Mini (EU)": ( POWER_DESCRIPTION, diff --git a/homeassistant/components/switcher_kis/config_flow.py b/homeassistant/components/switcher_kis/config_flow.py index ee015cb1a25c1d..d0803b117e2688 100644 --- a/homeassistant/components/switcher_kis/config_flow.py +++ b/homeassistant/components/switcher_kis/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_TOKEN, CONF_USERNAME -from .const import DOMAIN +from .const import DOMAIN, PREREQUISITES_URL from .utils import async_discover_devices _LOGGER = logging.getLogger(__name__) @@ -77,7 +77,10 @@ async def async_step_credentials( errors["base"] = "invalid_auth" return self.async_show_form( - step_id="credentials", data_schema=CONFIG_SCHEMA, errors=errors + step_id="credentials", + data_schema=CONFIG_SCHEMA, + errors=errors, + description_placeholders={"prerequisites_url": PREREQUISITES_URL}, ) async def async_step_reauth( @@ -106,6 +109,7 @@ async def async_step_reauth_confirm( step_id="reauth_confirm", data_schema=CONFIG_SCHEMA, errors=errors, + description_placeholders={"prerequisites_url": PREREQUISITES_URL}, ) async def _create_entry(self) -> ConfigFlowResult: diff --git a/homeassistant/components/switcher_kis/const.py b/homeassistant/components/switcher_kis/const.py index 9edc69e49463b9..6abf1c44312737 100644 --- a/homeassistant/components/switcher_kis/const.py +++ b/homeassistant/components/switcher_kis/const.py @@ -14,3 +14,7 @@ # Defines the maximum interval device must send an update before it marked unavailable MAX_UPDATE_INTERVAL_SEC = 30 + +PREREQUISITES_URL = ( + "https://www.home-assistant.io/integrations/switcher_kis/#prerequisites" +) diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index 5eece295aa83a0..33bbdc345d33f4 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -5,7 +5,7 @@ "description": "[%key:common::config_flow::description::confirm_setup%]" }, "credentials": { - "description": "Found a Switcher device that requires a token\nEnter your username and token\nFor more information see https://www.home-assistant.io/integrations/switcher_kis/#prerequisites", + "description": "Found a Switcher device that requires a token\nEnter your username and token\nFor more information see {prerequisites_url}", "data": { "username": "[%key:common::config_flow::data::username%]", "token": "[%key:common::config_flow::data::access_token%]" diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 1ed9aae1f22195..1de4f8841df0c5 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -13,16 +13,6 @@ from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY, DPCode, DPType from .models import EnumTypeData, IntegerTypeData -_DPTYPE_MAPPING: dict[str, DPType] = { - "bitmap": DPType.BITMAP, - "bool": DPType.BOOLEAN, - "enum": DPType.ENUM, - "json": DPType.JSON, - "raw": DPType.RAW, - "string": DPType.STRING, - "value": DPType.INTEGER, -} - class TuyaEntity(Entity): """Tuya base device.""" @@ -125,28 +115,6 @@ def find_dpcode( return None - def get_dptype( - self, dpcode: DPCode | None, *, prefer_function: bool = False - ) -> DPType | None: - """Find a matching DPCode data type available on for this device.""" - if dpcode is None: - return None - - order = ["status_range", "function"] - if prefer_function: - order = ["function", "status_range"] - for key in order: - if dpcode in getattr(self.device, key): - current_type = getattr(self.device, key)[dpcode].type - try: - return DPType(current_type) - except ValueError: - # Sometimes, we get ill-formed DPTypes from the cloud, - # this fixes them and maps them to the correct DPType. - return _DPTYPE_MAPPING.get(current_type) - - return None - async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index d2cceaa46204de..ebd13be689ae06 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -29,7 +29,7 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode from .entity import TuyaEntity from .models import IntegerTypeData -from .util import get_dpcode, remap_value +from .util import get_dpcode, get_dptype, remap_value @dataclass @@ -478,9 +478,9 @@ def __init__( description.brightness_min, dptype=DPType.INTEGER ) - if ( - dpcode := get_dpcode(self.device, description.color_data) - ) and self.get_dptype(dpcode, prefer_function=True) == DPType.JSON: + if (dpcode := get_dpcode(self.device, description.color_data)) and ( + get_dptype(self.device, dpcode, prefer_function=True) == DPType.JSON + ): self._color_data_dpcode = dpcode color_modes.add(ColorMode.HS) if dpcode in self.device.function: diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 0ad28cbc096470..7c9cabaff45c00 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -44,6 +44,7 @@ ) from .entity import TuyaEntity from .models import ComplexValue, ElectricityValue, EnumTypeData, IntegerTypeData +from .util import get_dptype _WIND_DIRECTIONS = { "north": 0.0, @@ -1689,7 +1690,7 @@ def __init__( self._type_data = enum_type self._type = DPType.ENUM else: - self._type = self.get_dptype(DPCode(description.key)) + self._type = get_dptype(self.device, DPCode(description.key)) # Logic to ensure the set device class and API received Unit Of Measurement # match Home Assistants requirements. diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index af6a78c1476375..6734f9a0a2a38c 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -6,7 +6,17 @@ from homeassistant.exceptions import ServiceValidationError -from .const import DOMAIN, DPCode +from .const import DOMAIN, DPCode, DPType + +_DPTYPE_MAPPING: dict[str, DPType] = { + "bitmap": DPType.BITMAP, + "bool": DPType.BOOLEAN, + "enum": DPType.ENUM, + "json": DPType.JSON, + "raw": DPType.RAW, + "string": DPType.STRING, + "value": DPType.INTEGER, +} def get_dpcode( @@ -32,6 +42,32 @@ def get_dpcode( return None +def get_dptype( + device: CustomerDevice, dpcode: DPCode | None, *, prefer_function: bool = False +) -> DPType | None: + """Find a matching DPType type information for this device DPCode.""" + if dpcode is None: + return None + + lookup_tuple = ( + (device.function, device.status_range) + if prefer_function + else (device.status_range, device.function) + ) + + for device_specs in lookup_tuple: + if current_definition := device_specs.get(dpcode): + current_type = current_definition.type + try: + return DPType(current_type) + except ValueError: + # Sometimes, we get ill-formed DPTypes from the cloud, + # this fixes them and maps them to the correct DPType. + return _DPTYPE_MAPPING.get(current_type) + + return None + + def remap_value( value: float, from_min: float = 0, diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index bbd03b070a477c..decbc8bb523143 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -13,6 +13,7 @@ Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.IMAGE, + Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/unifi/light.py b/homeassistant/components/unifi/light.py new file mode 100644 index 00000000000000..9327dcc160e075 --- /dev/null +++ b/homeassistant/components/unifi/light.py @@ -0,0 +1,172 @@ +"""Light platform for UniFi Network integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent +from aiounifi.interfaces.devices import Devices +from aiounifi.models.api import ApiItem +from aiounifi.models.device import Device, DeviceSetLedStatus + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, + LightEntityDescription, + LightEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import rgb_hex_to_rgb_list + +from . import UnifiConfigEntry +from .entity import ( + UnifiEntity, + UnifiEntityDescription, + async_device_available_fn, + async_device_device_info_fn, +) + +if TYPE_CHECKING: + from .hub import UnifiHub + + +@callback +def async_device_led_supported_fn(hub: UnifiHub, obj_id: str) -> bool: + """Check if device supports LED control.""" + device: Device = hub.api.devices[obj_id] + return device.supports_led_ring + + +@callback +def async_device_led_is_on_fn(hub: UnifiHub, device: Device) -> bool: + """Check if device LED is on.""" + return device.led_override == "on" + + +async def async_device_led_control_fn( + hub: UnifiHub, obj_id: str, turn_on: bool, **kwargs: Any +) -> None: + """Control device LED.""" + device = hub.api.devices[obj_id] + + status = "on" if turn_on else "off" + + brightness = ( + int((kwargs[ATTR_BRIGHTNESS] / 255) * 100) + if ATTR_BRIGHTNESS in kwargs + else device.led_override_color_brightness + ) + + color = ( + f"#{kwargs[ATTR_RGB_COLOR][0]:02x}{kwargs[ATTR_RGB_COLOR][1]:02x}{kwargs[ATTR_RGB_COLOR][2]:02x}" + if ATTR_RGB_COLOR in kwargs + else device.led_override_color + ) + + await hub.api.request( + DeviceSetLedStatus.create( + device=device, + status=status, + brightness=brightness, + color=color, + ) + ) + + +@dataclass(frozen=True, kw_only=True) +class UnifiLightEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( + LightEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT] +): + """Class describing UniFi light entity.""" + + control_fn: Callable[[UnifiHub, str, bool], Coroutine[Any, Any, None]] + is_on_fn: Callable[[UnifiHub, ApiItemT], bool] + + +ENTITY_DESCRIPTIONS: tuple[UnifiLightEntityDescription, ...] = ( + UnifiLightEntityDescription[Devices, Device]( + key="LED control", + translation_key="led_control", + allowed_fn=lambda hub, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + control_fn=async_device_led_control_fn, + device_info_fn=async_device_device_info_fn, + is_on_fn=async_device_led_is_on_fn, + name_fn=lambda device: "LED", + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=async_device_led_supported_fn, + unique_id_fn=lambda hub, obj_id: f"led-{obj_id}", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: UnifiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up lights for UniFi Network integration.""" + config_entry.runtime_data.entity_loader.register_platform( + async_add_entities, + UnifiLightEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, + ) + + +class UnifiLightEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( + UnifiEntity[HandlerT, ApiItemT], LightEntity +): + """Base representation of a UniFi light.""" + + entity_description: UnifiLightEntityDescription[HandlerT, ApiItemT] + _attr_supported_features = LightEntityFeature(0) + _attr_color_mode = ColorMode.RGB + _attr_supported_color_modes = {ColorMode.RGB} + + @callback + def async_initiate_state(self) -> None: + """Initiate entity state.""" + self.async_update_state(ItemEvent.ADDED, self._obj_id) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on light.""" + await self.entity_description.control_fn(self.hub, self._obj_id, True, **kwargs) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off light.""" + await self.entity_description.control_fn( + self.hub, self._obj_id, False, **kwargs + ) + + @callback + def async_update_state(self, event: ItemEvent, obj_id: str) -> None: + """Update entity state.""" + description = self.entity_description + device_obj = description.object_fn(self.api, self._obj_id) + + device = cast(Device, device_obj) + + self._attr_is_on = description.is_on_fn(self.hub, device_obj) + + brightness = device.led_override_color_brightness + self._attr_brightness = ( + int((int(brightness) / 100) * 255) if brightness is not None else None + ) + + hex_color = ( + device.led_override_color.lstrip("#") + if self._attr_is_on and device.led_override_color + else None + ) + if hex_color and len(hex_color) == 6: + rgb_list = rgb_hex_to_rgb_list(hex_color) + self._attr_rgb_color = (rgb_list[0], rgb_list[1], rgb_list[2]) + else: + self._attr_rgb_color = None diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 5b88055e62a2eb..3e357ab645fe92 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -34,6 +34,11 @@ } }, "entity": { + "light": { + "led_control": { + "name": "LED" + } + }, "sensor": { "device_state": { "state": { diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index e4a21004cfe5e4..fb9b7760f09aea 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -98,7 +98,7 @@ "postgres": "PostgreSQL", "mysql": "MySQL/MariaDB", "mongodb": "MongoDB", - "radius": "Radius", + "radius": "RADIUS", "redis": "Redis", "tailscale_ping": "Tailscale Ping", "snmp": "SNMP", diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 9f3a7bc9ba8af6..b4e4932a08b17c 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -156,17 +156,27 @@ def extra_state_attributes(self) -> dict[str, Any]: if hasattr(self.device.state, "active_time"): attr["active_time"] = self.device.state.active_time - if hasattr(self.device.state, "display_status"): + if ( + hasattr(self.device.state, "display_status") + and self.device.state.display_status is not None + ): attr["display_status"] = getattr( self.device.state.display_status, "value", None ) - if hasattr(self.device.state, "child_lock"): + if ( + hasattr(self.device.state, "child_lock") + and self.device.state.child_lock is not None + ): attr["child_lock"] = self.device.state.child_lock - if hasattr(self.device.state, "nightlight_status"): - attr["night_light"] = self.device.state.nightlight_status - + if ( + hasattr(self.device.state, "nightlight_status") + and self.device.state.nightlight_status is not None + ): + attr["night_light"] = getattr( + self.device.state.nightlight_status, "value", None + ) if hasattr(self.device.state, "mode"): attr["mode"] = self.device.state.mode diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index c1d4adda62a9bd..1963563e0f3367 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -23,6 +23,7 @@ DEFAULT_HEATING_TYPE, DOMAIN, VICARE_NAME, + VIESSMANN_DEVELOPER_PORTAL, HeatingType, ) from .utils import login @@ -70,6 +71,9 @@ async def async_step_user( return self.async_show_form( step_id="user", + description_placeholders={ + "viessmann_developer_portal": VIESSMANN_DEVELOPER_PORTAL + }, data_schema=USER_SCHEMA, errors=errors, ) @@ -102,6 +106,9 @@ async def async_step_reauth_confirm( return self.async_show_form( step_id="reauth_confirm", + description_placeholders={ + "viessmann_developer_portal": VIESSMANN_DEVELOPER_PORTAL + }, data_schema=self.add_suggested_values_to_schema( REAUTH_SCHEMA, reauth_entry.data ), diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index c874b9f173c6d9..c228020753118a 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -29,6 +29,8 @@ VICARE_NAME = "ViCare" VICARE_TOKEN_FILENAME = "vicare_token.save" +VIESSMANN_DEVELOPER_PORTAL = "https://app.developer.viessmann-climatesolutions.com" + CONF_CIRCUIT = "circuit" CONF_HEATING_TYPE = "heating_type" diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index f6a8fc3afafeba..fdf250a60c0eb0 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from .const import DOMAIN +from .const import DOMAIN, VIESSMANN_DEVELOPER_PORTAL class ViCareEntity(Entity): @@ -49,5 +49,5 @@ def __init__( name=model, manufacturer="Viessmann", model=model, - configuration_url="https://developer.viessmann.com/", + configuration_url=VIESSMANN_DEVELOPER_PORTAL, ) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 99c78e262a65a9..ee6ef6d1c75ac0 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -3,7 +3,7 @@ "flow_title": "{name}", "step": { "user": { - "description": "Set up ViCare integration. To generate client ID go to https://app.developer.viessmann.com", + "description": "Set up ViCare integration.", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", @@ -13,7 +13,7 @@ "data_description": { "username": "The email address to log in to your ViCare account.", "password": "The password to log in to your ViCare account.", - "client_id": "The ID of the API client created in the Viessmann developer portal.", + "client_id": "The ID of the API client created in the [Viessmann developer portal]({viessmann_developer_portal}).", "heating_type": "Allows to overrule the device auto detection." } }, diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 0433199b54e6e4..ded8b2ec3b8253 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -1,8 +1,12 @@ """Vodafone Station integration.""" -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from aiohttp import ClientSession, CookieJar +from aiovodafone.api import VodafoneStationCommonApi + +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant +from .const import _LOGGER, CONF_DEVICE_DETAILS, DEVICE_TYPE, DEVICE_URL from .coordinator import VodafoneConfigEntry, VodafoneStationRouter from .utils import async_client_session @@ -14,9 +18,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> session = await async_client_session(hass) coordinator = VodafoneStationRouter( hass, - entry.data[CONF_HOST], - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], entry, session, ) @@ -30,6 +31,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> return True +async def async_migrate_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version == 1 and entry.minor_version == 1: + _LOGGER.debug( + "Migrating from version %s.%s", entry.version, entry.minor_version + ) + + jar = CookieJar(unsafe=True, quote_cookie=False) + session = ClientSession(cookie_jar=jar) + + try: + device_type, url = await VodafoneStationCommonApi.get_device_type( + entry.data[CONF_HOST], + session, + ) + finally: + await session.close() + + # Save device details to config entry + new_data = entry.data.copy() + new_data.update( + { + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: device_type, + DEVICE_URL: str(url), + } + }, + ) + + hass.config_entries.async_update_entry( + entry, data=new_data, version=1, minor_version=2 + ) + + _LOGGER.info( + "Migration to version %s.%s successful", entry.version, entry.minor_version + ) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 13e30d389261e4..ab2335e7669771 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -5,7 +5,8 @@ from collections.abc import Mapping from typing import Any -from aiovodafone import VodafoneStationSercommApi, exceptions as aiovodafone_exceptions +from aiovodafone import exceptions as aiovodafone_exceptions +from aiovodafone.api import VodafoneStationCommonApi, init_api_class import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -20,7 +21,15 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN +from .const import ( + _LOGGER, + CONF_DEVICE_DETAILS, + DEFAULT_HOST, + DEFAULT_USERNAME, + DEVICE_TYPE, + DEVICE_URL, + DOMAIN, +) from .coordinator import VodafoneConfigEntry from .utils import async_client_session @@ -40,26 +49,37 @@ def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect.""" session = await async_client_session(hass) - api = VodafoneStationSercommApi( - data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD], session + + device_type, url = await VodafoneStationCommonApi.get_device_type( + data[CONF_HOST], + session, ) + api = init_api_class(url, device_type, data, session) + try: await api.login() finally: await api.logout() - return {"title": data[CONF_HOST]} + return { + "title": data[CONF_HOST], + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: device_type, + DEVICE_URL: str(url), + }, + } class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Vodafone Station.""" VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -97,7 +117,10 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=info["title"], + data=user_input | {CONF_DEVICE_DETAILS: info[CONF_DEVICE_DETAILS]}, + ) return self.async_show_form( step_id="user", data_schema=user_form_schema(user_input), errors=errors diff --git a/homeassistant/components/vodafone_station/const.py b/homeassistant/components/vodafone_station/const.py index 99f953d50d5f9d..0a74f423e2a064 100644 --- a/homeassistant/components/vodafone_station/const.py +++ b/homeassistant/components/vodafone_station/const.py @@ -7,6 +7,10 @@ DOMAIN = "vodafone_station" SCAN_INTERVAL = 30 +CONF_DEVICE_DETAILS = "device_details" +DEVICE_URL = "device_url" +DEVICE_TYPE = "device_type" + DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_HOST = "192.168.1.1" DEFAULT_USERNAME = "vodafone" diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 5a3330b16c6211..3648dee7795953 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -6,13 +6,16 @@ from typing import Any, cast from aiohttp import ClientSession -from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions +from aiovodafone import exceptions +from aiovodafone.api import VodafoneStationDevice, init_api_class +from yarl import URL from homeassistant.components.device_tracker import ( DEFAULT_CONSIDER_HOME, DOMAIN as DEVICE_TRACKER_DOMAIN, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er @@ -20,7 +23,14 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import _LOGGER, DOMAIN, SCAN_INTERVAL +from .const import ( + _LOGGER, + CONF_DEVICE_DETAILS, + DEVICE_TYPE, + DEVICE_URL, + DOMAIN, + SCAN_INTERVAL, +) from .helpers import cleanup_device_tracker CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds() @@ -53,16 +63,19 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): def __init__( self, hass: HomeAssistant, - host: str, - username: str, - password: str, config_entry: VodafoneConfigEntry, session: ClientSession, ) -> None: """Initialize the scanner.""" - self._host = host - self.api = VodafoneStationSercommApi(host, username, password, session) + data = config_entry.data + + self.api = init_api_class( + URL(data[CONF_DEVICE_DETAILS][DEVICE_URL]), + data[CONF_DEVICE_DETAILS][DEVICE_TYPE], + data, + session, + ) # Last resort as no MAC or S/N can be retrieved via API self._id = config_entry.unique_id @@ -70,7 +83,7 @@ def __init__( super().__init__( hass=hass, logger=_LOGGER, - name=f"{DOMAIN}-{host}-coordinator", + name=f"{DOMAIN}-{data[CONF_HOST]}-coordinator", update_interval=timedelta(seconds=SCAN_INTERVAL), config_entry=config_entry, ) @@ -117,7 +130,7 @@ def _calculate_update_time_and_consider_home( async def _async_update_data(self) -> UpdateCoordinatorDataType: """Update router data.""" - _LOGGER.debug("Polling Vodafone Station host: %s", self._host) + _LOGGER.debug("Polling Vodafone Station host: %s", self.api.base_url.host) try: await self.api.login() diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index a9ee2f49b4c659..001bfa4d5b715b 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiovodafone"], "quality_scale": "platinum", - "requirements": ["aiovodafone==1.2.1"] + "requirements": ["aiovodafone==2.0.1"] } diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py index 96e758e91f4b8a..cfdaf4dc192551 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -17,6 +17,7 @@ from .const import CONF_SIP_PORT, DOMAIN from .devices import VoIPDevices +from .store import VoipStore from .voip import HassVoipDatagramProtocol PLATFORMS = ( @@ -35,6 +36,8 @@ "async_unload_entry", ] +type VoipConfigEntry = ConfigEntry[VoipStore] + @dataclass class DomainData: @@ -45,7 +48,7 @@ class DomainData: devices: VoIPDevices -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool: """Set up VoIP integration from a config entry.""" # Make sure there is a valid user ID for VoIP in the config entry if ( @@ -59,9 +62,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data={**entry.data, "user": voip_user.id} ) + entry.runtime_data = VoipStore(hass, entry.entry_id) sip_port = entry.options.get(CONF_SIP_PORT, SIP_PORT) devices = VoIPDevices(hass, entry) - devices.async_setup() + await devices.async_setup() transport, protocol = await _create_sip_server( hass, lambda: HassVoipDatagramProtocol(hass, devices), @@ -102,7 +106,7 @@ async def _create_sip_server( return transport, protocol -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool: """Unload VoIP.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): _LOGGER.debug("Shutting down VoIP server") @@ -121,9 +125,11 @@ async def async_remove_config_entry_device( return True -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> None: """Remove VoIP entry.""" if "user" in entry.data and ( user := await hass.auth.async_get_user(entry.data["user"]) ): await hass.auth.async_remove_user(user) + + await entry.runtime_data.async_remove() diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 8d11cf2ff89329..20b5f9a7182f8d 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -119,6 +119,8 @@ def __init__( AssistSatelliteEntity.__init__(self) RtpDatagramProtocol.__init__(self) + _LOGGER.debug("Assist satellite with device: %s", voip_device) + self.config_entry = config_entry self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() @@ -254,7 +256,7 @@ async def _do_announce( ) try: - # VoIP ID is SIP header + # VoIP ID is SIP header - This represents what is set as the To header destination_endpoint = SipEndpoint(self.voip_device.voip_id) except ValueError: # VoIP ID is IP address @@ -269,10 +271,12 @@ async def _do_announce( # Make the call sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol + _LOGGER.debug("Outgoing call to contact %s", self.voip_device.contact) call_info = sip_protocol.outgoing_call( source=source_endpoint, destination=destination_endpoint, rtp_port=self._rtp_port, + contact=self.voip_device.contact, ) # Check if caller didn't pick up diff --git a/homeassistant/components/voip/const.py b/homeassistant/components/voip/const.py index 9a4403f9df2c58..87d6dc5f41514f 100644 --- a/homeassistant/components/voip/const.py +++ b/homeassistant/components/voip/const.py @@ -1,5 +1,7 @@ """Constants for the Voice over IP integration.""" +from typing import Final + DOMAIN = "voip" RATE = 16000 @@ -14,3 +16,5 @@ CONF_SIP_PORT = "sip_port" CONF_SIP_USER = "sip_user" + +STORAGE_VER: Final = 1 diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py index c33ec048cbd38f..028d51280b4235 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -4,15 +4,20 @@ from collections.abc import Callable, Iterator from dataclasses import dataclass, field +import logging from typing import Any from voip_utils import CallInfo, VoipDatagramProtocol +from voip_utils.sip import SipEndpoint from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN +from .store import DeviceContact, DeviceContacts, VoipStore + +_LOGGER = logging.getLogger(__name__) @dataclass @@ -24,6 +29,7 @@ class VoIPDevice: is_active: bool = False update_listeners: list[Callable[[VoIPDevice], None]] = field(default_factory=list) protocol: VoipDatagramProtocol | None = None + contact: SipEndpoint | None = None @callback def set_is_active(self, active: bool) -> None: @@ -80,9 +86,9 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self.config_entry = config_entry self._new_device_listeners: list[Callable[[VoIPDevice], None]] = [] self.devices: dict[str, VoIPDevice] = {} + self.device_store: VoipStore = config_entry.runtime_data - @callback - def async_setup(self) -> None: + async def async_setup(self) -> None: """Set up devices.""" for device in dr.async_entries_for_config_entry( dr.async_get(self.hass), self.config_entry.entry_id @@ -92,9 +98,13 @@ def async_setup(self) -> None: ) if voip_id is None: continue + devices_data: DeviceContacts = await self.device_store.async_load_devices() + device_data: DeviceContact | None = devices_data.get(voip_id) + _LOGGER.debug("Loaded device data for %s: %s", voip_id, device_data) self.devices[voip_id] = VoIPDevice( voip_id=voip_id, device_id=device.id, + contact=SipEndpoint(device_data.contact) if device_data else None, ) @callback @@ -185,12 +195,29 @@ def entity_migrator(entry: er.RegistryEntry) -> dict[str, Any] | None: ) if voip_device is not None: + if ( + call_info.contact_endpoint is not None + and voip_device.contact != call_info.contact_endpoint + ): + # Update VOIP device with contact information from call info + voip_device.contact = call_info.contact_endpoint + self.hass.async_create_task( + self.device_store.async_update_device( + voip_id, call_info.contact_endpoint.sip_header + ) + ) return voip_device voip_device = self.devices[voip_id] = VoIPDevice( - voip_id=voip_id, - device_id=device.id, + voip_id=voip_id, device_id=device.id, contact=call_info.contact_endpoint ) + if call_info.contact_endpoint is not None: + self.hass.async_create_task( + self.device_store.async_update_device( + voip_id, call_info.contact_endpoint.sip_header + ) + ) + for listener in self._new_device_listeners: listener(voip_device) diff --git a/homeassistant/components/voip/store.py b/homeassistant/components/voip/store.py new file mode 100644 index 00000000000000..5ceb73ae4c29db --- /dev/null +++ b/homeassistant/components/voip/store.py @@ -0,0 +1,54 @@ +"""VOIP contact storage.""" + +from dataclasses import dataclass +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import STORAGE_VER + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DeviceContact: + """Device contact data.""" + + contact: str + + +class DeviceContacts(dict[str, DeviceContact]): + """Map of device contact data.""" + + +class VoipStore(Store): + """Store for VOIP device contact information.""" + + def __init__(self, hass: HomeAssistant, storage_key: str) -> None: + """Initialize the VOIP Storage.""" + super().__init__(hass, STORAGE_VER, f"voip-{storage_key}") + + async def async_load_devices(self) -> DeviceContacts: + """Load data from store as DeviceContacts.""" + raw_data: dict[str, dict[str, str]] = await self.async_load() or {} + return self._dict_to_devices(raw_data) + + async def async_update_device(self, voip_id: str, contact_header: str) -> None: + """Update the device store with the contact information.""" + _LOGGER.debug("Saving new VOIP device %s contact %s", voip_id, contact_header) + devices_data: DeviceContacts = await self.async_load_devices() + _LOGGER.debug("devices_data: %s", devices_data) + device_data: DeviceContact | None = devices_data.get(voip_id) + if device_data is not None: + device_data.contact = contact_header + else: + devices_data[voip_id] = DeviceContact(contact_header) + await self.async_save(devices_data) + _LOGGER.debug("Saved new VOIP device contact") + + def _dict_to_devices(self, raw_data: dict[str, dict[str, str]]) -> DeviceContacts: + contacts = DeviceContacts() + for k, v in (raw_data or {}).items(): + contacts[k] = DeviceContact(**v) + return contacts diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a15d63b31e657a..099e113cf935b6 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -34,7 +34,12 @@ TemplateError, Unauthorized, ) -from homeassistant.helpers import config_validation as cv, entity, template +from homeassistant.helpers import ( + config_validation as cv, + entity, + target as target_helpers, + template, +) from homeassistant.helpers.condition import ( async_get_all_descriptions as async_get_all_condition_descriptions, async_subscribe_platform_events as async_subscribe_condition_platform_events, @@ -96,6 +101,7 @@ def async_register_commands( async_reg(hass, handle_call_service) async_reg(hass, handle_entity_source) async_reg(hass, handle_execute_script) + async_reg(hass, handle_extract_from_target) async_reg(hass, handle_fire_event) async_reg(hass, handle_get_config) async_reg(hass, handle_get_services) @@ -838,6 +844,39 @@ def handle_entity_source( connection.send_result(msg["id"], _serialize_entity_sources(entity_sources)) +@callback +@decorators.websocket_command( + { + vol.Required("type"): "extract_from_target", + vol.Required("target"): cv.TARGET_FIELDS, + vol.Optional("expand_group", default=False): bool, + } +) +def handle_extract_from_target( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle extract from target command.""" + + selector_data = target_helpers.TargetSelectorData(msg["target"]) + extracted = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, expand_group=msg["expand_group"] + ) + + extracted_dict = { + "referenced_entities": extracted.referenced.union( + extracted.indirectly_referenced + ), + "referenced_devices": extracted.referenced_devices, + "referenced_areas": extracted.referenced_areas, + "missing_devices": extracted.missing_devices, + "missing_areas": extracted.missing_areas, + "missing_floors": extracted.missing_floors, + "missing_labels": extracted.missing_labels, + } + + connection.send_result(msg["id"], extracted_dict) + + @decorators.websocket_command( { vol.Required("type"): "subscribe_trigger", diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 76837652ae5be5..e5835d0e67162c 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -79,10 +79,6 @@ def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: super().__init__(coordinator=coordinator) self._attr_unique_id = f"{coordinator.data.info.mac_address}_preset" - sorted_values = sorted( - coordinator.data.presets.values(), key=lambda preset: preset.name - ) - self._attr_options = [preset.name for preset in sorted_values] @property def available(self) -> bool: @@ -100,6 +96,14 @@ def current_option(self) -> str | None: return preset.name return None + @property + def options(self) -> list[str]: + """Return a list of selectable options.""" + sorted_values = sorted( + self.coordinator.data.presets.values(), key=lambda preset: preset.name + ) + return [preset.name for preset in sorted_values] + @wled_exception_handler async def async_select_option(self, option: str) -> None: """Set WLED segment to the selected preset.""" @@ -116,10 +120,6 @@ def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: super().__init__(coordinator=coordinator) self._attr_unique_id = f"{coordinator.data.info.mac_address}_playlist" - sorted_values = sorted( - coordinator.data.playlists.values(), key=lambda playlist: playlist.name - ) - self._attr_options = [playlist.name for playlist in sorted_values] @property def available(self) -> bool: @@ -137,6 +137,14 @@ def current_option(self) -> str | None: return playlist.name return None + @property + def options(self) -> list[str]: + """Return a list of selectable options.""" + sorted_values = sorted( + self.coordinator.data.playlists.values(), key=lambda playlist: playlist.name + ) + return [playlist.name for playlist in sorted_values] + @wled_exception_handler async def async_select_option(self, option: str) -> None: """Set WLED segment to the selected playlist.""" @@ -161,10 +169,6 @@ def __init__(self, coordinator: WLEDDataUpdateCoordinator, segment: int) -> None self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = f"{coordinator.data.info.mac_address}_palette_{segment}" - sorted_values = sorted( - coordinator.data.palettes.values(), key=lambda palette: palette.name - ) - self._attr_options = [palette.name for palette in sorted_values] self._segment = segment @property @@ -180,9 +184,22 @@ def available(self) -> bool: @property def current_option(self) -> str | None: """Return the current selected color palette.""" - return self.coordinator.data.palettes[ - int(self.coordinator.data.state.segments[self._segment].palette_id) - ].name + if not self.coordinator.data.palettes: + return None + if (segment := self.coordinator.data.state.segments.get(self._segment)) is None: + return None + palette_id = int(segment.palette_id) + if (palette := self.coordinator.data.palettes.get(palette_id)) is None: + return None + return palette.name + + @property + def options(self) -> list[str]: + """Return a list of selectable options.""" + sorted_values = sorted( + self.coordinator.data.palettes.values(), key=lambda palette: palette.name + ) + return [palette.name for palette in sorted_values] @wled_exception_handler async def async_select_option(self, option: str) -> None: diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index f5b44eb8fc4a0a..f8f1bd7880579f 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -536,7 +536,6 @@ def __init__( self._unsubs: list[Callable[[], None]] = [] self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol)) - self._reload_task: asyncio.Task | None = None config_entry.async_on_unload( self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, @@ -622,15 +621,7 @@ def handle_connection_lost(self, event: ConnectionLostEvent) -> None: """Handle a connection lost event.""" _LOGGER.debug("Connection to the radio was lost: %r", event) - - # Ensure we do not queue up multiple resets - if self._reload_task is not None: - _LOGGER.debug("Ignoring reset, one is already running") - return - - self._reload_task = self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id), - ) + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) @callback def handle_device_joined(self, event: DeviceJoinedEvent) -> None: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 3b18cdb3446b8d..5eb2d0f2bb8cd1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3031,8 +3031,9 @@ def _async_current_ids(self, include_ignore: bool = True) -> set[str | None]: """Return current unique IDs.""" return { entry.unique_id - for entry in self.hass.config_entries.async_entries(self.handler) - if include_ignore or entry.source != SOURCE_IGNORE + for entry in self.hass.config_entries.async_entries( + self.handler, include_ignore=include_ignore + ) } @callback diff --git a/homeassistant/const.py b/homeassistant/const.py index f5d6dd5b4a9c68..d8869dfd6e9cee 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -613,6 +613,7 @@ class UnitOfFrequency(StrEnum): class UnitOfPressure(StrEnum): """Pressure units.""" + MILLIPASCAL = "mPa" PA = "Pa" HPA = "hPa" KPA = "kPa" @@ -664,6 +665,7 @@ class UnitOfVolumeFlowRate(StrEnum): LITERS_PER_HOUR = "L/h" LITERS_PER_MINUTE = "L/min" LITERS_PER_SECOND = "L/s" + GALLONS_PER_HOUR = "gal/h" GALLONS_PER_MINUTE = "gal/min" MILLILITERS_PER_SECOND = "mL/s" diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index a562f86f1f9251..67d6ad55a3a407 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable +from contextlib import asynccontextmanager from logging import Logger +from typing import Any from homeassistant.core import HassJob, HomeAssistant, callback @@ -36,6 +38,7 @@ def __init__( self._timer_task: asyncio.TimerHandle | None = None self._execute_at_end_of_timer: bool = False self._execute_lock = asyncio.Lock() + self._execute_lock_owner: asyncio.Task[Any] | None = None self._background = background self._job: HassJob[[], _R_co] | None = ( None @@ -46,6 +49,22 @@ def __init__( ) self._shutdown_requested = False + @asynccontextmanager + async def async_lock(self) -> AsyncGenerator[None]: + """Return an async context manager to lock the debouncer.""" + if self._execute_lock_owner is asyncio.current_task(): + raise RuntimeError("Debouncer lock is not re-entrant") + + if self._execute_lock.locked(): + self.logger.debug("Debouncer lock is already acquired, waiting") + + async with self._execute_lock: + self._execute_lock_owner = asyncio.current_task() + try: + yield + finally: + self._execute_lock_owner = None + @property def function(self) -> Callable[[], _R_co] | None: """Return the function being wrapped by the Debouncer.""" @@ -98,7 +117,7 @@ async def async_call(self) -> None: if not self._async_schedule_or_call_now(): return - async with self._execute_lock: + async with self.async_lock(): # Abort if timer got set while we're waiting for the lock. if self._timer_task: return @@ -122,7 +141,7 @@ async def _handle_timer_finish(self) -> None: if self._execute_lock.locked(): return - async with self._execute_lock: + async with self.async_lock(): # Abort if timer got set while we're waiting for the lock. if self._timer_task: return diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index ef9c6b26b9f5b5..d2a7b62d09090e 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1045,7 +1045,7 @@ def _async_update_device( # noqa: C901 """Private update device attributes. :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id - :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_subentry_id + :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_entry_id """ old = self.devices[device_id] @@ -1346,7 +1346,7 @@ def async_update_device( """Update device attributes. :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id - :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_subentry_id + :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_entry_id """ if suggested_area is not UNDEFINED: report_usage( diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 85da7eaf87f899..27164e8fcf395d 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -128,10 +128,10 @@ def __init__( logger, cooldown=REQUEST_REFRESH_DEFAULT_COOLDOWN, immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE, - function=self.async_refresh, + function=self._async_refresh, ) else: - request_refresh_debouncer.function = self.async_refresh + request_refresh_debouncer.function = self._async_refresh self._debounced_refresh = request_refresh_debouncer @@ -277,7 +277,8 @@ def __wrap_handle_refresh_interval(self) -> None: async def _handle_refresh_interval(self, _now: datetime | None = None) -> None: """Handle a refresh interval occurrence.""" self._unsub_refresh = None - await self._async_refresh(log_failures=True, scheduled=True) + async with self._debounced_refresh.async_lock(): + await self._async_refresh(log_failures=True, scheduled=True) async def async_request_refresh(self) -> None: """Request a refresh. @@ -295,6 +296,16 @@ async def _async_update_data(self) -> _DataT: async def async_config_entry_first_refresh(self) -> None: """Refresh data for the first time when a config entry is setup. + Will automatically raise ConfigEntryNotReady if the refresh + fails. Additionally logging is handled by config entry setup + to ensure that multiple retries do not cause log spam. + """ + async with self._debounced_refresh.async_lock(): + await self._async_config_entry_first_refresh() + + async def _async_config_entry_first_refresh(self) -> None: + """Refresh data for the first time when a config entry is setup. + Will automatically raise ConfigEntryNotReady if the refresh fails. Additionally logging is handled by config entry setup to ensure that multiple retries do not cause log spam. @@ -364,7 +375,8 @@ async def _async_setup(self) -> None: async def async_refresh(self) -> None: """Refresh data and log errors.""" - await self._async_refresh(log_failures=True) + async with self._debounced_refresh.async_lock(): + await self._async_refresh(log_failures=True) async def _async_refresh( # noqa: C901 self, diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 6fa59923e8192b..89d06b3132d759 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -7,6 +7,7 @@ from contextlib import contextmanager import dataclasses from datetime import datetime +import errno import fcntl from io import TextIOWrapper import json @@ -207,11 +208,20 @@ def new_event_loop(self) -> asyncio.AbstractEventLoop: @callback -def _async_loop_exception_handler(_: Any, context: dict[str, Any]) -> None: +def _async_loop_exception_handler( + loop: asyncio.AbstractEventLoop, + context: dict[str, Any], +) -> None: """Handle all exception inside the core loop.""" + fatal_error: str | None = None kwargs = {} if exception := context.get("exception"): kwargs["exc_info"] = (type(exception), exception, exception.__traceback__) + if isinstance(exception, OSError) and exception.errno == errno.EMFILE: + # Too many open files – something is leaking them, and it's likely + # to be quite unrecoverable if the event loop can't pump messages + # (e.g. unable to accept a socket). + fatal_error = str(exception) logger = logging.getLogger(__package__) if source_traceback := context.get("source_traceback"): @@ -232,6 +242,14 @@ def _async_loop_exception_handler(_: Any, context: dict[str, Any]) -> None: **kwargs, # type: ignore[arg-type] ) + if fatal_error: + logger.error( + "Fatal error '%s' raised in event loop, shutting it down", + fatal_error, + ) + loop.stop() + loop.close() + async def setup_and_run_hass(runtime_config: RuntimeConfig) -> int: """Set up Home Assistant and run.""" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index d57913ee39794b..c3deae749a9edd 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -464,6 +464,7 @@ class PressureConverter(BaseUnitConverter): UNIT_CLASS = "pressure" _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfPressure.MILLIPASCAL: 1 * 1000, UnitOfPressure.PA: 1, UnitOfPressure.HPA: 1 / 100, UnitOfPressure.KPA: 1 / 1000, @@ -478,6 +479,7 @@ class PressureConverter(BaseUnitConverter): / (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY), } VALID_UNITS = { + UnitOfPressure.MILLIPASCAL, UnitOfPressure.PA, UnitOfPressure.HPA, UnitOfPressure.KPA, @@ -824,6 +826,7 @@ class VolumeFlowRateConverter(BaseUnitConverter): UnitOfVolumeFlowRate.LITERS_PER_MINUTE: 1 / (_HRS_TO_MINUTES * _L_TO_CUBIC_METER), UnitOfVolumeFlowRate.LITERS_PER_SECOND: 1 / (_HRS_TO_SECS * _L_TO_CUBIC_METER), + UnitOfVolumeFlowRate.GALLONS_PER_HOUR: 1 / _GALLON_TO_CUBIC_METER, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 1 / (_HRS_TO_MINUTES * _GALLON_TO_CUBIC_METER), UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND: 1 @@ -837,6 +840,7 @@ class VolumeFlowRateConverter(BaseUnitConverter): UnitOfVolumeFlowRate.LITERS_PER_HOUR, UnitOfVolumeFlowRate.LITERS_PER_MINUTE, UnitOfVolumeFlowRate.LITERS_PER_SECOND, + UnitOfVolumeFlowRate.GALLONS_PER_HOUR, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, } diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 3268520e3f66e3..4bc79a4da22c8a 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -376,6 +376,7 @@ def _deprecated_unit_system(value: str) -> str: ("pressure", UnitOfPressure.MBAR): UnitOfPressure.PSI, ("pressure", UnitOfPressure.CBAR): UnitOfPressure.PSI, ("pressure", UnitOfPressure.BAR): UnitOfPressure.PSI, + ("pressure", UnitOfPressure.MILLIPASCAL): UnitOfPressure.PSI, ("pressure", UnitOfPressure.PA): UnitOfPressure.PSI, ("pressure", UnitOfPressure.HPA): UnitOfPressure.PSI, ("pressure", UnitOfPressure.KPA): UnitOfPressure.PSI, diff --git a/machine/build.yaml b/machine/build.yaml index 2f8aa3fe5c3e4b..9926c5088fb39a 100644 --- a/machine/build.yaml +++ b/machine/build.yaml @@ -5,9 +5,6 @@ build_from: armhf: "ghcr.io/home-assistant/armhf-homeassistant:" amd64: "ghcr.io/home-assistant/amd64-homeassistant:" i386: "ghcr.io/home-assistant/i386-homeassistant:" -codenotary: - signer: notary@home-assistant.io - base_image: notary@home-assistant.io cosign: base_identity: https://github.com/home-assistant/core/.* identity: https://github.com/home-assistant/core/.* diff --git a/requirements_all.txt b/requirements_all.txt index efaed1186fd92f..bd526904c8e231 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -175,6 +175,9 @@ aio-geojson-usgs-earthquakes==0.3 # homeassistant.components.gdacs aio-georss-gdacs==0.10 +# homeassistant.components.onewire +aio-ownet==0.0.3 + # homeassistant.components.acaia aioacaia==0.1.17 @@ -429,7 +432,7 @@ aiousbwatcher==1.1.1 aiovlc==0.5.1 # homeassistant.components.vodafone_station -aiovodafone==1.2.1 +aiovodafone==2.0.1 # homeassistant.components.waqi aiowaqi==3.1.0 @@ -1270,7 +1273,7 @@ inkbird-ble==1.1.0 insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire -intellifire4py==4.1.9 +intellifire4py==4.2.1 # homeassistant.components.iometer iometer==0.2.0 @@ -1400,7 +1403,7 @@ loqedAPI==2.1.10 luftdaten==0.7.4 # homeassistant.components.lunatone -lunatone-rest-api-client==0.4.8 +lunatone-rest-api-client==0.5.3 # homeassistant.components.lupusec lupupy==0.3.2 @@ -1794,7 +1797,7 @@ py-improv-ble-client==1.0.3 py-madvr2==1.6.40 # homeassistant.components.melissa -py-melissa-climate==2.1.4 +py-melissa-climate==3.0.2 # homeassistant.components.nextbus py-nextbusnext==2.3.0 @@ -2268,9 +2271,6 @@ pyotp==2.9.0 # homeassistant.components.overkiz pyoverkiz==1.19.0 -# homeassistant.components.onewire -pyownet==0.10.0.post1 - # homeassistant.components.palazzetti pypalazzetti==0.1.19 @@ -3147,7 +3147,7 @@ weatherflow4py==1.4.1 webexpythonsdk==2.0.1 # homeassistant.components.nasweb -webio-api==0.1.11 +webio-api==0.1.12 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6168fd3505e0b6..bba0afb793b5dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -163,6 +163,9 @@ aio-geojson-usgs-earthquakes==0.3 # homeassistant.components.gdacs aio-georss-gdacs==0.10 +# homeassistant.components.onewire +aio-ownet==0.0.3 + # homeassistant.components.acaia aioacaia==0.1.17 @@ -411,7 +414,7 @@ aiousbwatcher==1.1.1 aiovlc==0.5.1 # homeassistant.components.vodafone_station -aiovodafone==1.2.1 +aiovodafone==2.0.1 # homeassistant.components.waqi aiowaqi==3.1.0 @@ -1107,7 +1110,7 @@ inkbird-ble==1.1.0 insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire -intellifire4py==4.1.9 +intellifire4py==4.2.1 # homeassistant.components.iometer iometer==0.2.0 @@ -1204,7 +1207,7 @@ loqedAPI==2.1.10 luftdaten==0.7.4 # homeassistant.components.lunatone -lunatone-rest-api-client==0.4.8 +lunatone-rest-api-client==0.5.3 # homeassistant.components.lupusec lupupy==0.3.2 @@ -1523,7 +1526,7 @@ py-improv-ble-client==1.0.3 py-madvr2==1.6.40 # homeassistant.components.melissa -py-melissa-climate==2.1.4 +py-melissa-climate==3.0.2 # homeassistant.components.nextbus py-nextbusnext==2.3.0 @@ -1898,9 +1901,6 @@ pyotp==2.9.0 # homeassistant.components.overkiz pyoverkiz==1.19.0 -# homeassistant.components.onewire -pyownet==0.10.0.post1 - # homeassistant.components.palazzetti pypalazzetti==0.1.19 @@ -2606,7 +2606,7 @@ watergate-local-api==2024.4.1 weatherflow4py==1.4.1 # homeassistant.components.nasweb -webio-api==0.1.11 +webio-api==0.1.12 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/script/install_integration_requirements.py b/script/install_integration_requirements.py index 74fd1c93be55c0..4e4c911c827f64 100644 --- a/script/install_integration_requirements.py +++ b/script/install_integration_requirements.py @@ -46,6 +46,8 @@ def main() -> int | None: "-c", "homeassistant/package_constraints.txt", "-U", + "--python", + sys.executable, *sorted(all_requirements), # Sort for consistent output ] print(" ".join(cmd)) diff --git a/tests/components/airos/snapshots/test_binary_sensor.ambr b/tests/components/airos/snapshots/test_binary_sensor.ambr index d9815e0c62bd15..65705c7f629647 100644 --- a/tests/components/airos/snapshots/test_binary_sensor.ambr +++ b/tests/components/airos/snapshots/test_binary_sensor.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhcp_client', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_client', + 'unique_id': '01:23:45:67:89:AB_dhcp_client', 'unit_of_measurement': None, }) # --- @@ -79,7 +79,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhcp_server', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_server', + 'unique_id': '01:23:45:67:89:AB_dhcp_server', 'unit_of_measurement': None, }) # --- @@ -128,7 +128,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dhcp6_server', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp6_server', + 'unique_id': '01:23:45:67:89:AB_dhcp6_server', 'unit_of_measurement': None, }) # --- @@ -177,7 +177,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'port_forwarding', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_portfw', + 'unique_id': '01:23:45:67:89:AB_portfw', 'unit_of_measurement': None, }) # --- @@ -225,7 +225,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pppoe', - 'unique_id': '03aa0d0b40fed0a47088293584ef5432_pppoe', + 'unique_id': '01:23:45:67:89:AB_pppoe', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 8f668166ea6676..59aae6ad4ca3ec 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -11,7 +11,7 @@ import pytest from homeassistant.components.airos.const import DOMAIN, SECTION_ADVANCED_SETTINGS -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -26,6 +26,7 @@ NEW_PASSWORD = "new_password" REAUTH_STEP = "reauth_confirm" +RECONFIGURE_STEP = "reconfigure" MOCK_CONFIG = { CONF_HOST: "1.1.1.1", @@ -253,3 +254,151 @@ async def test_reauth_unique_id_mismatch( updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) assert updated_entry.data[CONF_PASSWORD] != NEW_PASSWORD + + +async def test_successful_reconfigure( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful reconfigure.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == RECONFIGURE_STEP + + user_input = { + CONF_PASSWORD: NEW_PASSWORD, + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: True, + CONF_VERIFY_SSL: True, + }, + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD + assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True + assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is True + + assert updated_entry.data[CONF_HOST] == MOCK_CONFIG[CONF_HOST] + assert updated_entry.data[CONF_USERNAME] == MOCK_CONFIG[CONF_USERNAME] + + +@pytest.mark.parametrize( + ("reconfigure_exception", "expected_error"), + [ + (AirOSConnectionAuthenticationError, "invalid_auth"), + (AirOSDeviceConnectionError, "cannot_connect"), + (AirOSKeyDataMissingError, "key_data_missing"), + (Exception, "unknown"), + ], + ids=[ + "invalid_auth", + "cannot_connect", + "key_data_missing", + "unknown", + ], +) +async def test_reconfigure_flow_failure( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + reconfigure_exception: Exception, + expected_error: str, +) -> None: + """Test reconfigure from start (failure) to finish (success).""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + + user_input = { + CONF_PASSWORD: NEW_PASSWORD, + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: True, + CONF_VERIFY_SSL: True, + }, + } + + mock_airos_client.login.side_effect = reconfigure_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == RECONFIGURE_STEP + assert result["errors"] == {"base": expected_error} + + mock_airos_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration failure when the unique ID changes.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + flow_id = result["flow_id"] + + mock_airos_client.status.return_value.derived.mac = "FF:23:45:67:89:AB" + + user_input = { + CONF_PASSWORD: NEW_PASSWORD, + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: True, + CONF_VERIFY_SSL: True, + }, + } + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input=user_input, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] == MOCK_CONFIG[CONF_PASSWORD] + assert ( + updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] + == MOCK_CONFIG[SECTION_ADVANCED_SETTINGS][CONF_SSL] + ) diff --git a/tests/components/airos/test_init.py b/tests/components/airos/test_init.py index 30e2498d7d763f..f0c9d0831069f9 100644 --- a/tests/components/airos/test_init.py +++ b/tests/components/airos/test_init.py @@ -4,12 +4,16 @@ from unittest.mock import ANY, MagicMock +import pytest + from homeassistant.components.airos.const import ( DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS, ) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -19,6 +23,7 @@ CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -108,8 +113,10 @@ async def test_setup_entry_without_ssl( assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False -async def test_migrate_entry(hass: HomeAssistant, mock_airos_client: MagicMock) -> None: - """Test migrate entry unique id.""" +async def test_ssl_migrate_entry( + hass: HomeAssistant, mock_airos_client: MagicMock +) -> None: + """Test migrate entry SSL options.""" entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, @@ -124,11 +131,77 @@ async def test_migrate_entry(hass: HomeAssistant, mock_airos_client: MagicMock) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.version == 1 - assert entry.minor_version == 2 + assert entry.version == 2 + assert entry.minor_version == 1 assert entry.data == MOCK_CONFIG_V1_2 +@pytest.mark.parametrize( + ("sensor_domain", "sensor_name", "mock_id"), + [ + (BINARY_SENSOR_DOMAIN, "port_forwarding", "device_id_12345"), + (SENSOR_DOMAIN, "antenna_gain", "01:23:45:67:89:ab"), + ], +) +async def test_uid_migrate_entry( + hass: HomeAssistant, + mock_airos_client: MagicMock, + device_registry: dr.DeviceRegistry, + sensor_domain: str, + sensor_name: str, + mock_id: str, +) -> None: + """Test migrate entry unique id.""" + entity_registry = er.async_get(hass) + + MOCK_MAC = dr.format_mac("01:23:45:67:89:AB") + MOCK_ID = "device_id_12345" + old_unique_id = f"{mock_id}_{sensor_name}" + new_unique_id = f"{MOCK_MAC}_{sensor_name}" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=MOCK_CONFIG_V1_2, + entry_id="1", + unique_id=mock_id, + version=1, + minor_version=2, + ) + entry.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, MOCK_ID)}, + connections={ + (dr.CONNECTION_NETWORK_MAC, MOCK_MAC), + }, + ) + await hass.async_block_till_done() + + old_entity_entry = entity_registry.async_get_or_create( + DOMAIN, sensor_domain, old_unique_id, config_entry=entry + ) + original_entity_id = old_entity_entry.entity_id + + hass.config_entries.async_update_entry(entry, unique_id=MOCK_MAC) + await hass.async_block_till_done() + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + updated_entity_entry = entity_registry.async_get(original_entity_id) + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 2 + assert entry.minor_version == 1 + assert ( + entity_registry.async_get_entity_id(sensor_domain, DOMAIN, old_unique_id) + is None + ) + assert updated_entity_entry.unique_id == new_unique_id + + async def test_migrate_future_return( hass: HomeAssistant, mock_airos_client: MagicMock, @@ -140,7 +213,7 @@ async def test_migrate_future_return( data=MOCK_CONFIG_V1_2, entry_id="1", unique_id="airos_device", - version=2, + version=3, ) entry.add_to_hass(hass) diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index f7d20687c926f6..10d379427db62e 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -374,7 +374,7 @@ async def test_login_exist_user_ip_changes( @pytest.mark.usefixtures("current_request_with_host") # Has example.com host @pytest.mark.parametrize( - ("config", "expected_url_prefix"), + ("config", "expected_url_prefix", "extra_response_data"), [ ( { @@ -383,6 +383,7 @@ async def test_login_exist_user_ip_changes( "external_url": "https://example.com", }, "https://example.com", + {"issuer": "https://example.com"}, ), ( { @@ -391,6 +392,7 @@ async def test_login_exist_user_ip_changes( "external_url": "https://other.com", }, "https://example.com", + {"issuer": "https://example.com"}, ), ( { @@ -399,6 +401,7 @@ async def test_login_exist_user_ip_changes( "external_url": "https://again.com", }, "", + {}, ), ], ids=["external_url", "internal_url", "no_match"], @@ -408,6 +411,7 @@ async def test_well_known_auth_info( aiohttp_client: ClientSessionGenerator, config: dict[str, str], expected_url_prefix: str, + extra_response_data: dict[str, str], ) -> None: """Test the well-known OAuth authorization server endpoint with different URL configurations.""" await async_process_ha_core_config(hass, config) @@ -417,6 +421,7 @@ async def test_well_known_auth_info( ) assert resp.status == 200 assert await resp.json() == { + **extra_response_data, "authorization_endpoint": f"{expected_url_prefix}/auth/authorize", "token_endpoint": f"{expected_url_prefix}/auth/token", "revocation_endpoint": f"{expected_url_prefix}/auth/revoke", diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index b5a79724ddbacf..adbf985c8ade6d 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -19,7 +19,6 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util.dt import utcnow from . import init_integration, mock_device, mock_location, mock_reading @@ -126,8 +125,7 @@ async def test_sensors_attributes_pro(hass: HomeAssistant, canary) -> None: future = utcnow() + timedelta(seconds=30) async_fire_time_changed(hass, future) - await async_update_entity(hass, entity_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state2 = hass.states.get(entity_id) assert state2 @@ -142,8 +140,7 @@ async def test_sensors_attributes_pro(hass: HomeAssistant, canary) -> None: future += timedelta(seconds=30) async_fire_time_changed(hass, future) - await async_update_entity(hass, entity_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state3 = hass.states.get(entity_id) assert state3 diff --git a/tests/components/control4/__init__.py b/tests/components/control4/__init__.py index 8995968d5ddf6a..5c937acca4a00a 100644 --- a/tests/components/control4/__init__.py +++ b/tests/components/control4/__init__.py @@ -1 +1,18 @@ """Tests for the Control4 integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up the Control4 integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/control4/conftest.py b/tests/components/control4/conftest.py new file mode 100644 index 00000000000000..f8e174b9d95794 --- /dev/null +++ b/tests/components/control4/conftest.py @@ -0,0 +1,103 @@ +"""Common fixtures for the Control4 tests.""" + +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.control4.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry, load_fixture + +MOCK_HOST = "192.168.1.100" +MOCK_USERNAME = "test-username" +MOCK_PASSWORD = "test-password" +MOCK_CONTROLLER_UNIQUE_ID = "control4_test_123" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + "controller_unique_id": MOCK_CONTROLLER_UNIQUE_ID, + }, + unique_id="00:aa:00:aa:00:aa", + ) + + +@pytest.fixture +def mock_c4_account() -> Generator[MagicMock]: + """Mock a Control4 Account client.""" + with patch( + "homeassistant.components.control4.C4Account", autospec=True + ) as mock_account_class: + mock_account = mock_account_class.return_value + mock_account.getAccountBearerToken = AsyncMock() + mock_account.getAccountControllers = AsyncMock( + return_value={"href": "https://example.com"} + ) + mock_account.getDirectorBearerToken = AsyncMock(return_value={"token": "test"}) + mock_account.getControllerOSVersion = AsyncMock(return_value="3.2.0") + yield mock_account + + +@pytest.fixture +def mock_c4_director() -> Generator[MagicMock]: + """Mock a Control4 Director client.""" + with patch( + "homeassistant.components.control4.C4Director", autospec=True + ) as mock_director_class: + mock_director = mock_director_class.return_value + # Default: Multi-room setup (room with sources, room without sources) + # Note: The API returns JSON strings, so we load fixtures as strings + mock_director.getAllItemInfo = AsyncMock( + return_value=load_fixture("director_all_items.json", DOMAIN) + ) + mock_director.getUiConfiguration = AsyncMock( + return_value=load_fixture("ui_configuration.json", DOMAIN) + ) + yield mock_director + + +@pytest.fixture +def mock_update_variables() -> Generator[AsyncMock]: + """Mock the update_variables_for_config_entry function.""" + + async def _mock_update_variables(*args, **kwargs): + return { + 1: { + "POWER_STATE": True, + "CURRENT_VOLUME": 50, + "IS_MUTED": False, + "CURRENT_VIDEO_DEVICE": 100, + "CURRENT MEDIA INFO": {}, + "PLAYING": False, + "PAUSED": False, + "STOPPED": False, + } + } + + with patch( + "homeassistant.components.control4.media_player.update_variables_for_config_entry", + new=_mock_update_variables, + ) as mock_update: + yield mock_update + + +@pytest.fixture +def platforms() -> list[str]: + """Platforms which should be loaded during the test.""" + return ["media_player"] + + +@pytest.fixture(autouse=True) +async def mock_patch_platforms(platforms: list[str]) -> AsyncGenerator[None]: + """Fixture to set up platforms for tests.""" + with patch("homeassistant.components.control4.PLATFORMS", platforms): + yield diff --git a/tests/components/control4/fixtures/director_all_items.json b/tests/components/control4/fixtures/director_all_items.json new file mode 100644 index 00000000000000..40e44c1178b905 --- /dev/null +++ b/tests/components/control4/fixtures/director_all_items.json @@ -0,0 +1,18 @@ +[ + { + "id": 1, + "typeName": "room", + "name": "Living Room", + "roomHidden": false + }, + { + "id": 2, + "typeName": "room", + "name": "Thermostat Room", + "roomHidden": false + }, + { + "id": 100, + "name": "TV" + } +] diff --git a/tests/components/control4/fixtures/ui_configuration.json b/tests/components/control4/fixtures/ui_configuration.json new file mode 100644 index 00000000000000..67f33b2318f872 --- /dev/null +++ b/tests/components/control4/fixtures/ui_configuration.json @@ -0,0 +1,15 @@ +{ + "experiences": [ + { + "room_id": 1, + "type": "watch", + "sources": { + "source": [ + { + "id": 100 + } + ] + } + } + ] +} diff --git a/tests/components/control4/snapshots/test_media_player.ambr b/tests/components/control4/snapshots/test_media_player.ambr new file mode 100644 index 00000000000000..63ea93b7859c80 --- /dev/null +++ b/tests/components/control4/snapshots/test_media_player.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_media_player_with_and_without_sources[media_player.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'TV', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.living_room', + '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': 'control4', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_with_and_without_sources[media_player.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': 'Living Room', + 'is_volume_muted': False, + 'source_list': list([ + 'TV', + ]), + 'supported_features': , + 'volume_level': 0.5, + }), + 'context': , + 'entity_id': 'media_player.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py index 9a1b392f61ccf5..edab8c271647bf 100644 --- a/tests/components/control4/test_config_flow.py +++ b/tests/components/control4/test_config_flow.py @@ -17,6 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME + from tests.common import MockConfigEntry @@ -69,9 +71,9 @@ async def test_form(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, }, ) await hass.async_block_till_done() @@ -79,11 +81,12 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "control4_model_00AA00AA00AA" assert result2["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, "controller_unique_id": "control4_model_00AA00AA00AA", } + assert result2["result"].unique_id == "00:aa:00:aa:00:aa" assert len(mock_setup_entry.mock_calls) == 1 @@ -100,9 +103,9 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, }, ) @@ -123,9 +126,9 @@ async def test_form_unexpected_exception(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, }, ) @@ -152,9 +155,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_HOST: MOCK_HOST, + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, }, ) diff --git a/tests/components/control4/test_media_player.py b/tests/components/control4/test_media_player.py new file mode 100644 index 00000000000000..8b08a9ee65f4b4 --- /dev/null +++ b/tests/components/control4/test_media_player.py @@ -0,0 +1,26 @@ +"""Test Control4 Media Player.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_c4_account", "mock_c4_director", "mock_update_variables") +async def test_media_player_with_and_without_sources( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that rooms with sources create entities and rooms without are skipped.""" + # The default mock_c4_director fixture provides multi-room data: + # Room 1 has video source, Room 2 has no sources (thermostat-only room) + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py index 5e2d9446cdc485..09e9fc9f07dfce 100644 --- a/tests/components/derivative/test_config_flow.py +++ b/tests/components/derivative/test_config_flow.py @@ -1,14 +1,18 @@ """Test the Derivative config flow.""" +from datetime import timedelta from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant import config_entries from homeassistant.components.derivative.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import selector +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, get_schema_suggested_value @@ -154,3 +158,86 @@ async def test_options( await hass.async_block_till_done() state = hass.states.get(f"{platform}.my_derivative") assert state.attributes["unit_of_measurement"] == "cat/h" + + +async def test_update_unit(hass: HomeAssistant) -> None: + """Test behavior of changing the unit_time option.""" + # Setup the config entry + source_id = "sensor.source" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": source_id, + "unit_time": "min", + "time_window": {"seconds": 0.0}, + }, + title="My derivative", + ) + derivative_id = "sensor.my_derivative" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(derivative_id) + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get("unit_of_measurement") is None + + time = dt_util.utcnow() + with freeze_time(time) as freezer: + # First state update of the source. + # Derivative does not learn the unit yet. + hass.states.async_set(source_id, 5, {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(derivative_id) + assert state.state == "0.0" + assert state.attributes.get("unit_of_measurement") is None + + # Second state update of the source. + time += timedelta(minutes=1) + freezer.move_to(time) + hass.states.async_set(source_id, "7", {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(derivative_id) + assert state.state == "2.0" + assert state.attributes.get("unit_of_measurement") == "dogs/min" + + # Update the unit_time from minutes to seconds. + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "source": source_id, + "round": 1.0, + "unit_time": "s", + "time_window": {"seconds": 0.0}, + }, + ) + await hass.async_block_till_done() + + # Check the state after reconfigure. Neither unit or state has changed. + state = hass.states.get(derivative_id) + assert state.state == "2.0" + assert state.attributes.get("unit_of_measurement") == "dogs/min" + + # Third state update of the source. + time += timedelta(seconds=1) + freezer.move_to(time) + hass.states.async_set(source_id, "10", {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(derivative_id) + assert state.state == "3.0" + # While the state is correctly reporting a state of 3 dogs per second, it incorrectly keeps + # the unit as dogs/min + assert state.attributes.get("unit_of_measurement") == "dogs/min" + + # Fourth state update of the source. + time += timedelta(seconds=1) + freezer.move_to(time) + hass.states.async_set(source_id, "20", {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(derivative_id) + assert state.state == "10.0" + assert state.attributes.get("unit_of_measurement") == "dogs/min" diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 5a601ad26dd65f..52b7ae725ea750 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -934,3 +934,65 @@ async def test_unavailable_boot( assert state is not None # Now that the source sensor has two valid datapoints, we can calculate derivative assert state.state == "5.00" + + +async def test_source_unit_change( + hass: HomeAssistant, +) -> None: + """Test how derivative responds when the source sensor changes unit.""" + source_id = "sensor.source" + config = { + "sensor": { + "platform": "derivative", + "name": "derivative", + "source": source_id, + "unit_time": "s", + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + entity_id = "sensor.derivative" + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get("unit_of_measurement") is None + + time = dt_util.utcnow() + with freeze_time(time) as freezer: + # First state update of the source. + # Derivative does not learn the UoM yet. + hass.states.async_set(source_id, "5", {"unit_of_measurement": "cats"}) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "0.000" + assert state.attributes.get("unit_of_measurement") is None + + # Second state update of the source. + time += timedelta(seconds=1) + freezer.move_to(time) + hass.states.async_set(source_id, "7", {"unit_of_measurement": "cats"}) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "2.000" + assert state.attributes.get("unit_of_measurement") == "cats/s" + + # Third state update of the source, source unit changes to dogs. + # Ignored by derivative which continues reporting cats. + time += timedelta(seconds=1) + freezer.move_to(time) + hass.states.async_set(source_id, "12", {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "5.000" + assert state.attributes.get("unit_of_measurement") == "cats/s" + + # Fourth state update of the source, still dogs. + # Ignored by derivative which continues reporting cats. + time += timedelta(seconds=1) + freezer.move_to(time) + hass.states.async_set(source_id, "20", {"unit_of_measurement": "dogs"}) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "8.000" + assert state.attributes.get("unit_of_measurement") == "cats/s" diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index 9edb1d4233141c..8ecb71ddfe0762 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -48,7 +48,10 @@ async def test_pin_request_succeeds(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authorize" - assert result["description_placeholders"] == {"pin": "test-pin"} + assert result["description_placeholders"] == { + "pin": "test-pin", + "auth_url": "https://www.ecobee.com/consumerportal/index.html", + } async def test_pin_request_fails(hass: HomeAssistant) -> None: @@ -107,4 +110,7 @@ async def test_token_request_fails(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authorize" assert result["errors"]["base"] == "token_request_failed" - assert result["description_placeholders"] == {"pin": "test-pin"} + assert result["description_placeholders"] == { + "pin": "test-pin", + "auth_url": "https://www.ecobee.com/consumerportal/index.html", + } diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index af8233d46fd7f6..da518e78ef9aec 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -7,6 +7,7 @@ from homeassistant.components.energy import data, is_configured from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.models import StatisticMeanType from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -365,8 +366,8 @@ async def test_fossil_energy_consumption_no_co2( }, ) external_energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_1", @@ -400,8 +401,8 @@ async def test_fossil_energy_consumption_no_co2( }, ) external_energy_metadata_2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_2", @@ -532,8 +533,8 @@ async def test_fossil_energy_consumption_hole( }, ) external_energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_1", @@ -567,8 +568,8 @@ async def test_fossil_energy_consumption_hole( }, ) external_energy_metadata_2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_2", @@ -697,8 +698,8 @@ async def test_fossil_energy_consumption_no_data( }, ) external_energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_1", @@ -732,8 +733,8 @@ async def test_fossil_energy_consumption_no_data( }, ) external_energy_metadata_2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_2", @@ -851,8 +852,8 @@ async def test_fossil_energy_consumption( }, ) external_energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_1", @@ -886,8 +887,8 @@ async def test_fossil_energy_consumption( }, ) external_energy_metadata_2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_2", @@ -917,8 +918,8 @@ async def test_fossil_energy_consumption( }, ) external_co2_metadata = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Fossil percentage", "source": "test", "statistic_id": "test:fossil_percentage", @@ -1105,8 +1106,8 @@ async def test_fossil_energy_consumption_check_missing_hour( }, ) energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -1140,8 +1141,8 @@ async def test_fossil_energy_consumption_check_missing_hour( }, ) co2_metadata = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Fossil percentage", "source": "test", "statistic_id": "test:fossil_percentage", @@ -1202,8 +1203,8 @@ async def test_fossil_energy_consumption_missing_sum( {"start": period4, "last_reset": None, "state": 3, "mean": 5}, ) external_energy_metadata_1 = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Mean imported energy", "source": "test", "statistic_id": "test:mean_energy_import_tariff", diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index 00cb30fce09d0d..dcd4d8ba3d8df2 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -13996,6 +13996,54 @@ 'state': '2025-07-19T17:17:31+00:00', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_admin_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.collar_482520020939_admin_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Admin state', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'admin_state', + 'unique_id': '482520020939_admin_state_str', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_admin_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Collar 482520020939 Admin state', + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_admin_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on_grid', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_grid_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index fb7458a1a5b73f..0dbab47b6f5697 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -2208,7 +2208,6 @@ async def test_user_flow_name_conflict_overwrite( result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { CONF_HOST: "127.0.0.1", CONF_PORT: 6053, @@ -2572,16 +2571,15 @@ async def test_reconfig_name_conflict_overwrite( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"} ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" - assert result["data"] == { - CONF_HOST: "127.0.0.2", - CONF_PORT: 6053, - CONF_PASSWORD: "", - CONF_NOISE_PSK: "", - CONF_DEVICE_NAME: "test", - } - assert result["context"]["unique_id"] == "11:22:33:44:55:bb" + assert ( + hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, "11:22:33:44:55:bb" + ) + is not None + ) assert ( hass.config_entries.async_entry_for_domain_unique_id( DOMAIN, "11:22:33:44:55:aa" diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f96ab8aca2a090..9d7c66b0d2474c 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -922,7 +922,7 @@ async def test_coordinator_updates( supervisor_client.refresh_updates.assert_not_called() async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Scheduled refresh, no update refresh call supervisor_client.refresh_updates.assert_not_called() @@ -944,7 +944,7 @@ async def test_coordinator_updates( async_fire_time_changed( hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) supervisor_client.refresh_updates.assert_called_once() supervisor_client.refresh_updates.reset_mock() diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 088a9e9c349c39..6d7b0af1d5d740 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -83,7 +83,7 @@ async def test_demo_statistics_growth(hass: HomeAssistant) -> None: "statistic_id": statistic_id, "unit_class": "volume", "unit_of_measurement": "m³", - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } statistics = [ diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py index bc9e44d2e099a5..b10d3d2df33652 100644 --- a/tests/components/lunatone/__init__.py +++ b/tests/components/lunatone/__init__.py @@ -17,6 +17,7 @@ from tests.common import MockConfigEntry BASE_URL: Final = "http://10.0.0.131" +PRODUCT_NAME: Final = "Test Product" SERIAL_NUMBER: Final = 12345 VERSION: Final = "v1.14.1/1.4.3" diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py index 5f60d084788c19..21a80891dd9117 100644 --- a/tests/components/lunatone/conftest.py +++ b/tests/components/lunatone/conftest.py @@ -9,7 +9,7 @@ from homeassistant.components.lunatone.const import DOMAIN from homeassistant.const import CONF_URL -from . import BASE_URL, DEVICES_DATA, INFO_DATA, SERIAL_NUMBER +from . import BASE_URL, DEVICES_DATA, INFO_DATA, PRODUCT_NAME, SERIAL_NUMBER from tests.common import MockConfigEntry @@ -68,6 +68,7 @@ def mock_lunatone_info() -> Generator[AsyncMock]: info.name = info.data.name info.version = info.data.version info.serial_number = info.data.device.serial + info.product_name = PRODUCT_NAME yield info diff --git a/tests/components/lunatone/test_init.py b/tests/components/lunatone/test_init.py index 0e063b25adbf9c..d8813e332103ef 100644 --- a/tests/components/lunatone/test_init.py +++ b/tests/components/lunatone/test_init.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import BASE_URL, VERSION, setup_integration +from . import BASE_URL, PRODUCT_NAME, VERSION, setup_integration from tests.common import MockConfigEntry @@ -33,6 +33,7 @@ async def test_load_unload_config_entry( assert device_entry.manufacturer == "Lunatone" assert device_entry.sw_version == VERSION assert device_entry.configuration_url == BASE_URL + assert device_entry.model == PRODUCT_NAME await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 0451ef25d148c9..5b02453e44e0c9 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -565,6 +565,25 @@ "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", }, } +MOCK_SUBENTRY_SIREN_COMPONENT = { + "3faf1318023c46c5aea26707eeb6f12e": { + "platform": "siren", + "name": "Siren", + "entity_category": None, + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{ value }}", + "command_off_template": "{{ value }}", + "value_template": "{{ value_json.value }}", + "payload_off": "OFF", + "payload_on": "ON", + "available_tones": ["Happy hour", "Cooling alarm"], + "support_volume_set": True, + "support_duration": True, + "entity_picture": "https://example.com/3faf1318023c46c5aea26707eeb6f12e", + "optimistic": True, + }, +} MOCK_SUBENTRY_SWITCH_COMPONENT = { "3faf1318016c46c5aea26707eeb6f12e": { "platform": "switch", @@ -698,6 +717,10 @@ "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET, } +MOCK_SIREN_SUBENTRY_DATA = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_SIREN_COMPONENT, +} MOCK_SWITCH_SUBENTRY_DATA = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SWITCH_COMPONENT, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 6185e2ffae18e3..03d669c2d38c08 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -60,6 +60,7 @@ MOCK_SENSOR_SUBENTRY_DATA, MOCK_SENSOR_SUBENTRY_DATA_LAST_RESET_TEMPLATE, MOCK_SENSOR_SUBENTRY_DATA_STATE_CLASS, + MOCK_SIREN_SUBENTRY_DATA, MOCK_SWITCH_SUBENTRY_DATA, ) @@ -3655,6 +3656,41 @@ async def test_migrate_of_incompatible_config_entry( "Milk notifier Energy", id="sensor_total", ), + pytest.param( + MOCK_SIREN_SUBENTRY_DATA, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Siren"}, + {}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "optimistic": True, + "available_tones": ["Happy hour", "Cooling alarm"], + "support_duration": True, + "support_volume_set": True, + "siren_advanced_settings": { + "command_off_template": "{{ value }}", + }, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Siren", + id="siren", + ), pytest.param( MOCK_SWITCH_SUBENTRY_DATA, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, diff --git a/tests/components/nederlandse_spoorwegen/conftest.py b/tests/components/nederlandse_spoorwegen/conftest.py index 4589ecd2c2e391..8729a0cd5b34d3 100644 --- a/tests/components/nederlandse_spoorwegen/conftest.py +++ b/tests/components/nederlandse_spoorwegen/conftest.py @@ -8,14 +8,17 @@ from homeassistant.components.nederlandse_spoorwegen.const import ( CONF_FROM, + CONF_TIME, CONF_TO, CONF_VIA, DOMAIN, + INTEGRATION_TITLE, + SUBENTRY_TYPE_ROUTE, ) from homeassistant.config_entries import ConfigSubentryDataWithId from homeassistant.const import CONF_API_KEY, CONF_NAME -from .const import API_KEY +from .const import API_KEY, SUBENTRY_ID_1, SUBENTRY_ID_2 from tests.common import MockConfigEntry, load_json_object_fixture @@ -39,7 +42,7 @@ def mock_nsapi() -> Generator[AsyncMock]: autospec=True, ) as mock_nsapi, patch( - "homeassistant.components.nederlandse_spoorwegen.NSAPI", + "homeassistant.components.nederlandse_spoorwegen.coordinator.NSAPI", new=mock_nsapi, ), ): @@ -57,7 +60,7 @@ def mock_nsapi() -> Generator[AsyncMock]: def mock_config_entry() -> MockConfigEntry: """Mock config entry.""" return MockConfigEntry( - title="Nederlandse Spoorwegen", + title=INTEGRATION_TITLE, data={CONF_API_KEY: API_KEY}, domain=DOMAIN, subentries_data=[ @@ -67,11 +70,25 @@ def mock_config_entry() -> MockConfigEntry: CONF_FROM: "Ams", CONF_TO: "Rot", CONF_VIA: "Ht", + CONF_TIME: None, }, - subentry_type="route", + subentry_type=SUBENTRY_TYPE_ROUTE, title="Test Route", unique_id=None, - subentry_id="01K721DZPMEN39R5DK0ATBMSY8", + subentry_id=SUBENTRY_ID_1, + ), + ConfigSubentryDataWithId( + data={ + CONF_NAME: "To home", + CONF_FROM: "Hag", + CONF_TO: "Utr", + CONF_VIA: None, + CONF_TIME: "08:00", + }, + subentry_type=SUBENTRY_TYPE_ROUTE, + title="Test Route", + unique_id=None, + subentry_id=SUBENTRY_ID_2, ), ], ) diff --git a/tests/components/nederlandse_spoorwegen/const.py b/tests/components/nederlandse_spoorwegen/const.py index 92c2a6e58f9088..cc13d0fd9a88fe 100644 --- a/tests/components/nederlandse_spoorwegen/const.py +++ b/tests/components/nederlandse_spoorwegen/const.py @@ -1,3 +1,15 @@ """Constants for the Nederlandse Spoorwegen integration tests.""" API_KEY = "abc1234567" + +# Date/time format strings +DATETIME_FORMAT_LENGTH = 16 # "DD-MM-YYYY HH:MM" format +DATE_SEPARATOR = "-" +DATETIME_SPACE = " " +TIME_SEPARATOR = ":" + +# Test route names +TEST_ROUTE_TITLE_1 = "Route 1" +TEST_ROUTE_TITLE_2 = "Route 2" +SUBENTRY_ID_1 = "01K721DZPMEN39R5DK0ATBMSY8" +SUBENTRY_ID_2 = "01K721DZPMEN39R5DK0ATBMSY9" diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr new file mode 100644 index 00000000000000..96b3def82e77f4 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_device_registry_integration + list([ + 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( + 'nederlandse_spoorwegen', + '01K721DZPMEN39R5DK0ATBMSY8', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Nederlandse Spoorwegen', + 'model': 'Route', + 'model_id': None, + 'name': 'To work', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + 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( + 'nederlandse_spoorwegen', + '01K721DZPMEN39R5DK0ATBMSY9', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Nederlandse Spoorwegen', + 'model': 'Route', + 'model_id': None, + 'name': 'To home', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr index d3c311acb664f7..565970b953f62b 100644 --- a/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_sensor.ambr @@ -1,4 +1,76 @@ # serializer version: 1 +# name: test_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_sensor[sensor.to_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'arrival_delay': False, + 'arrival_platform_actual': '12', + 'arrival_platform_planned': '12', + 'arrival_time_actual': '18:37', + 'arrival_time_planned': '18:37', + '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': '16:46', + '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_sensor[sensor.to_work-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/nederlandse_spoorwegen/test_config_flow.py b/tests/components/nederlandse_spoorwegen/test_config_flow.py index 8d0c8e2b451abd..264a057a0c5779 100644 --- a/tests/components/nederlandse_spoorwegen/test_config_flow.py +++ b/tests/components/nederlandse_spoorwegen/test_config_flow.py @@ -53,7 +53,7 @@ async def test_creating_route( ) -> None: """Test creating a route after setting up the main config entry.""" mock_config_entry.add_to_hass(hass) - assert len(mock_config_entry.subentries) == 1 + assert len(mock_config_entry.subentries) == 2 result = await hass.config_entries.subentries.async_init( (mock_config_entry.entry_id, "route"), context={"source": SOURCE_USER} ) @@ -80,7 +80,7 @@ async def test_creating_route( CONF_NAME: "Home to Work", CONF_TIME: "08:30", } - assert len(mock_config_entry.subentries) == 2 + assert len(mock_config_entry.subentries) == 3 @pytest.mark.parametrize( @@ -136,7 +136,7 @@ async def test_fetching_stations_failed( ) -> None: """Test creating a route after setting up the main config entry.""" mock_config_entry.add_to_hass(hass) - assert len(mock_config_entry.subentries) == 1 + assert len(mock_config_entry.subentries) == 2 mock_nsapi.get_stations.side_effect = RequestsConnectionError("Unexpected error") result = await hass.config_entries.subentries.async_init( (mock_config_entry.entry_id, "route"), context={"source": SOURCE_USER} diff --git a/tests/components/nederlandse_spoorwegen/test_init.py b/tests/components/nederlandse_spoorwegen/test_init.py new file mode 100644 index 00000000000000..551cc4771d38a0 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/test_init.py @@ -0,0 +1,30 @@ +"""Test the Nederlandse Spoorwegen init.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_device_registry_integration( + hass: HomeAssistant, + mock_nsapi, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device registry integration creates correct devices.""" + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + # Get all devices created for this config entry + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + # Snapshot the devices to ensure they have the correct structure + assert device_entries == snapshot diff --git a/tests/components/nederlandse_spoorwegen/test_sensor.py b/tests/components/nederlandse_spoorwegen/test_sensor.py index ab578248bccef6..28839f633f1c99 100644 --- a/tests/components/nederlandse_spoorwegen/test_sensor.py +++ b/tests/components/nederlandse_spoorwegen/test_sensor.py @@ -3,16 +3,21 @@ from unittest.mock import AsyncMock import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError from syrupy.assertion import SnapshotAssertion from homeassistant.components.nederlandse_spoorwegen.const import ( CONF_FROM, CONF_ROUTES, + CONF_TIME, CONF_TO, CONF_VIA, DOMAIN, + INTEGRATION_TITLE, + SUBENTRY_TYPE_ROUTE, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigSubentryDataWithId from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_PLATFORM from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.entity_registry as er @@ -72,3 +77,83 @@ async def test_sensor( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensor_with_api_connection_error( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor behavior when API connection fails.""" + # Make API calls fail from the start + mock_nsapi.get_trips.side_effect = RequestsConnectionError("Connection failed") + + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + # Sensors should not be created at all if initial API call fails + sensor_states = hass.states.async_all("sensor") + assert len(sensor_states) == 0 + + +@pytest.mark.parametrize( + ("time_input", "route_name", "description"), + [ + (None, "Current time route", "No specific time - should use current time"), + ("08:30", "Morning commute", "Time only - should use today's date with time"), + ("08:30:45", "Early commute", "Time with seconds - should truncate seconds"), + ], +) +async def test_sensor_with_custom_time_parsing( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + time_input, + route_name, + description, +) -> None: + """Test sensor with different time parsing scenarios.""" + # Create a config entry with a route that has the specified time + config_entry = MockConfigEntry( + title=INTEGRATION_TITLE, + data={CONF_API_KEY: API_KEY}, + domain=DOMAIN, + subentries_data=[ + ConfigSubentryDataWithId( + data={ + CONF_NAME: route_name, + CONF_FROM: "Ams", + CONF_TO: "Rot", + CONF_VIA: "Ht", + CONF_TIME: time_input, + }, + subentry_type=SUBENTRY_TYPE_ROUTE, + title=f"{route_name} Route", + unique_id=None, + subentry_id=f"test_route_{time_input or 'none'}".replace(":", "_") + .replace("-", "_") + .replace(" ", "_"), + ), + ], + ) + + await setup_integration(hass, config_entry) + await hass.async_block_till_done() + + # Should create one sensor for the route with time parsing + sensor_states = hass.states.async_all("sensor") + assert len(sensor_states) == 1 + + # Verify sensor was created successfully with time parsing + state = sensor_states[0] + assert state is not None + assert state.state != "unavailable" + assert state.attributes.get("attribution") == "Data provided by NS" + assert state.attributes.get("device_class") == "timestamp" + assert state.attributes.get("icon") == "mdi:train" + + # The sensor should have a friendly name based on the route name + friendly_name = state.attributes.get("friendly_name", "").lower() + assert ( + route_name.lower() in friendly_name + or route_name.replace(" ", "_").lower() in state.entity_id + ) diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index 595b660b72200d..cc58f57e74b129 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import MagicMock -from pyownet.protocol import ProtocolError +from aio_ownet.exceptions import OWServerProtocolError from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES @@ -23,7 +23,7 @@ def setup_owproxy_mock_devices(owproxy: MagicMock, device_ids: list[str]) -> Non for device_id in device_ids: _setup_owproxy_mock_device(dir_side_effect, read_side_effect, device_id) - def _dir(path: str) -> Any: + async def _dir(path: str) -> Any: if (side_effect := dir_side_effect.get(path)) is None: raise NotImplementedError(f"Unexpected _dir call: {path}") result = side_effect.pop(0) @@ -33,11 +33,11 @@ def _dir(path: str) -> Any: raise result return result - def _read(path: str) -> Any: + async def _read(path: str) -> Any: if (side_effect := read_side_effect.get(path)) is None: raise NotImplementedError(f"Unexpected _read call: {path}") if len(side_effect) == 0: - raise ProtocolError(f"Missing injected value for: {path}") + raise OWServerProtocolError(f"Missing injected value for: {path}") result = side_effect.pop(0) if isinstance(result, Exception) or ( isinstance(result, type) and issubclass(result, Exception) diff --git a/tests/components/onewire/conftest.py b/tests/components/onewire/conftest.py index 9d4303eaa1c664..35d319d580a06f 100644 --- a/tests/components/onewire/conftest.py +++ b/tests/components/onewire/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from pyownet.protocol import ConnError +from aio_ownet.exceptions import OWServerConnectionError import pytest from homeassistant.components.onewire.const import DOMAIN @@ -56,15 +56,15 @@ def get_config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture(name="owproxy") def get_owproxy() -> Generator[MagicMock]: """Mock owproxy.""" - with patch("homeassistant.components.onewire.onewirehub.protocol.proxy") as owproxy: + with patch( + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy", + autospec=True, + ) as owproxy: yield owproxy @pytest.fixture(name="owproxy_with_connerror") -def get_owproxy_with_connerror() -> Generator[MagicMock]: +def get_owproxy_with_connerror(owproxy: MagicMock) -> MagicMock: """Mock owproxy.""" - with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", - side_effect=ConnError, - ) as owproxy: - yield owproxy + owproxy.return_value.validate.side_effect = OWServerConnectionError + return owproxy diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 32804bca28ec14..c113bd592ede80 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -1,6 +1,6 @@ """Constants for 1-Wire integration.""" -from pyownet.protocol import ProtocolError +from aio_ownet.exceptions import OWServerProtocolError ATTR_INJECT_READS = "inject_reads" @@ -49,7 +49,7 @@ }, "16.111111111111": { # Test case for issue #115984, where the device type cannot be read - ATTR_INJECT_READS: {"/type": [ProtocolError()]}, + ATTR_INJECT_READS: {"/type": [OWServerProtocolError()]}, }, "1F.111111111111": { ATTR_INJECT_READS: {"/type": [b"DS2409"]}, @@ -82,7 +82,7 @@ "22.111111111111": { ATTR_INJECT_READS: { "/type": [b"DS1822"], - "/temperature": [ProtocolError], + "/temperature": [OWServerProtocolError], }, }, "26.111111111111": { @@ -93,7 +93,7 @@ "/HIH3600/humidity": [b" 73.7563"], "/HIH4000/humidity": [b" 74.7563"], "/HIH5030/humidity": [b" 75.7563"], - "/HTM1735/humidity": [ProtocolError], + "/HTM1735/humidity": [OWServerProtocolError], "/B1-R1-A/pressure": [b" 969.265"], "/S3-R1-A/illuminance": [b" 65.8839"], "/VAD": [b" 2.97"], @@ -129,7 +129,7 @@ "/PIO.0": [b" 1"], "/PIO.1": [b" 0"], "/PIO.2": [b" 1"], - "/PIO.3": [ProtocolError], + "/PIO.3": [OWServerProtocolError], "/PIO.4": [b" 1"], "/PIO.5": [b" 0"], "/PIO.6": [b" 1"], @@ -145,7 +145,7 @@ "/sensed.0": [b" 1"], "/sensed.1": [b" 0"], "/sensed.2": [b" 0"], - "/sensed.3": [ProtocolError], + "/sensed.3": [OWServerProtocolError], "/sensed.4": [b" 0"], "/sensed.5": [b" 0"], "/sensed.6": [b" 0"], @@ -190,7 +190,7 @@ "/HIH3600/humidity": [b" 73.7563"], "/HIH4000/humidity": [b" 74.7563"], "/HIH5030/humidity": [b" 75.7563"], - "/HTM1735/humidity": [ProtocolError], + "/HTM1735/humidity": [OWServerProtocolError], "/B1-R1-A/pressure": [b" 969.265"], "/S3-R1-A/illuminance": [b" 65.8839"], "/VAD": [b" 2.97"], diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index 65bdaafc1311dc..68edaef51f5f92 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -3,7 +3,7 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, patch -from pyownet import protocol +from aio_ownet.exceptions import OWServerConnectionError import pytest from homeassistant.components.onewire.const import ( @@ -65,7 +65,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -86,8 +86,8 @@ async def test_user_flow_recovery(hass: HomeAssistant) -> None: # Invalid server with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", - side_effect=protocol.ConnError, + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", + side_effect=OWServerConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -100,7 +100,7 @@ async def test_user_flow_recovery(hass: HomeAssistant) -> None: # Valid server with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -148,8 +148,8 @@ async def test_reconfigure_flow( # Invalid server with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", - side_effect=protocol.ConnError, + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", + side_effect=OWServerConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -162,7 +162,7 @@ async def test_reconfigure_flow( # Valid server with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -222,8 +222,8 @@ async def test_hassio_flow(hass: HomeAssistant) -> None: # Cannot connect to server => retry with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", - side_effect=protocol.ConnError, + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", + side_effect=OWServerConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -236,7 +236,7 @@ async def test_hassio_flow(hass: HomeAssistant) -> None: # Connect OK with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -274,8 +274,8 @@ async def test_zeroconf_flow(hass: HomeAssistant) -> None: # Cannot connect to server => retry with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", - side_effect=protocol.ConnError, + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", + side_effect=OWServerConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -288,7 +288,7 @@ async def test_zeroconf_flow(hass: HomeAssistant) -> None: # Connect OK with patch( - "homeassistant.components.onewire.onewirehub.protocol.proxy", + "homeassistant.components.onewire.onewirehub.OWServerStatelessProxy.validate", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index ace7afb56451ec..2716c579036d73 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -2,8 +2,8 @@ from unittest.mock import MagicMock, patch +from aio_ownet.exceptions import OWServerReturnError from freezegun.api import FrozenDateTimeFactory -from pyownet import protocol import pytest from syrupy.assertion import SnapshotAssertion @@ -38,7 +38,8 @@ async def test_listing_failure( hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock ) -> None: """Test listing failure raises ConfigEntryNotReady.""" - owproxy.return_value.dir.side_effect = protocol.OwnetError() + owproxy.return_value.read.side_effect = OWServerReturnError(-1) + owproxy.return_value.dir.side_effect = OWServerReturnError(-1) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -47,9 +48,11 @@ async def test_listing_failure( assert config_entry.state is ConfigEntryState.SETUP_RETRY -@pytest.mark.usefixtures("owproxy") -async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_unload_entry( + hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock +) -> None: """Test being able to unload an entry.""" + setup_owproxy_mock_devices(owproxy, []) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index f1ef2dfa11bdbb..db63196ef3dec1 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -5,8 +5,8 @@ import logging from unittest.mock import MagicMock, _patch_dict, patch +from aio_ownet.exceptions import OWServerReturnError from freezegun.api import FrozenDateTimeFactory -from pyownet.protocol import OwnetError import pytest from syrupy.assertion import SnapshotAssertion @@ -85,8 +85,12 @@ async def test_tai8570_sensors( """ mock_devices = deepcopy(MOCK_OWPROXY_DEVICES) mock_device = mock_devices[device_id] - mock_device[ATTR_INJECT_READS]["/TAI8570/temperature"] = [OwnetError] - mock_device[ATTR_INJECT_READS]["/TAI8570/pressure"] = [OwnetError] + mock_device[ATTR_INJECT_READS]["/TAI8570/temperature"] = [ + OWServerReturnError(2, "legacy", "/12.111111111111/TAI8570/temperature") + ] + mock_device[ATTR_INJECT_READS]["/TAI8570/pressure"] = [ + OWServerReturnError(2, "legacy", "/12.111111111111/TAI8570/pressure") + ] with _patch_dict(MOCK_OWPROXY_DEVICES, mock_devices): setup_owproxy_mock_devices(owproxy, [device_id]) diff --git a/tests/components/opower/test_coordinator.py b/tests/components/opower/test_coordinator.py index 29a27f66a0cce5..1e251e8688da92 100644 --- a/tests/components/opower/test_coordinator.py +++ b/tests/components/opower/test_coordinator.py @@ -10,7 +10,11 @@ from homeassistant.components.opower.const import DOMAIN from homeassistant.components.opower.coordinator import OpowerCoordinator from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -186,6 +190,7 @@ async def test_coordinator_migration( statistic_id = "opower:pge_elec_111111_energy_consumption" metadata = StatisticMetaData( has_sum=True, + mean_type=StatisticMeanType.NONE, name="Opower pge elec 111111 consumption", source=DOMAIN, statistic_id=statistic_id, diff --git a/tests/components/playstation_network/test_init.py b/tests/components/playstation_network/test_init.py index e5a361a3cfbe9c..f3fc37a7264264 100644 --- a/tests/components/playstation_network/test_init.py +++ b/tests/components/playstation_network/test_init.py @@ -157,8 +157,8 @@ async def test_trophy_title_coordinator_auth_failed( freezer.tick(timedelta(days=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done(wait_background_tasks=True) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -194,8 +194,8 @@ async def test_trophy_title_coordinator_update_data_failed( freezer.tick(timedelta(days=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done(wait_background_tasks=True) runtime_data: PlaystationNetworkRuntimeData = config_entry.runtime_data assert runtime_data.trophy_titles.last_update_success is False @@ -254,8 +254,8 @@ async def test_trophy_title_coordinator_play_new_game( freezer.tick(timedelta(days=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 2 diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 65d74f3651c73e..e51129b643dee9 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -14,6 +14,7 @@ delete_statistics_duplicates, delete_statistics_meta_duplicates, ) +from homeassistant.components.recorder.models import StatisticMeanType from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant @@ -59,8 +60,8 @@ async def test_duplicate_statistics_handle_integrity_error( period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) external_energy_metadata_1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import_tariff_1", diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 303780eacc339d..e532e06226838e 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -408,8 +408,8 @@ def sensor_stats(entity_id, start): """Generate fake statistics.""" return { "meta": { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": None, "statistic_id": entity_id, "unit_class": None, @@ -675,8 +675,8 @@ async def test_rename_entity_collision( # Insert metadata for sensor.test99 metadata_1 = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "test", "statistic_id": "sensor.test99", @@ -782,8 +782,8 @@ async def test_rename_entity_collision_states_meta_check_disabled( # Insert metadata for sensor.test99 metadata_1 = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "test", "statistic_id": "sensor.test99", @@ -867,6 +867,13 @@ async def test_statistics_duplicated( {"unit_class": "energy"}, ], ) +@pytest.mark.parametrize( + ("external_metadata_extra_2"), + [ + {"has_mean": False}, + {"mean_type": StatisticMeanType.NONE}, + ], +) @pytest.mark.parametrize( ("source", "statistic_id", "import_fn"), [ @@ -880,6 +887,7 @@ async def test_import_statistics( hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, external_metadata_extra: dict[str, str], + external_metadata_extra_2: dict[str, Any], source, statistic_id, import_fn, @@ -910,14 +918,17 @@ async def test_import_statistics( "sum": 3, } - external_metadata = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": source, - "statistic_id": statistic_id, - "unit_of_measurement": "kWh", - } | external_metadata_extra + external_metadata = ( + { + "has_sum": True, + "name": "Total imported energy", + "source": source, + "statistic_id": statistic_id, + "unit_of_measurement": "kWh", + } + | external_metadata_extra + | external_metadata_extra_2 + ) import_fn(hass, external_metadata, (external_statistics1, external_statistics2)) await async_wait_recording_done(hass) @@ -1144,8 +1155,8 @@ async def test_external_statistics_errors( } _external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -1233,8 +1244,8 @@ async def test_import_statistics_errors( } _external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import", @@ -1571,8 +1582,8 @@ async def test_daily_statistics_sum( }, ) external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -1753,8 +1764,8 @@ async def test_multiple_daily_statistics_sum( }, ) external_metadata1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy 1", "source": "test", "statistic_id": "test:total_energy_import2", @@ -1762,8 +1773,8 @@ async def test_multiple_daily_statistics_sum( "unit_of_measurement": "kWh", } external_metadata2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy 2", "source": "test", "statistic_id": "test:total_energy_import1", @@ -1953,8 +1964,8 @@ async def test_weekly_statistics_mean( }, ) external_metadata = { - "has_mean": True, "has_sum": False, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -2100,8 +2111,8 @@ async def test_weekly_statistics_sum( }, ) external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -2282,8 +2293,8 @@ async def test_monthly_statistics_sum( }, ) external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -2612,8 +2623,8 @@ async def test_change( }, ) external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import", @@ -2949,8 +2960,8 @@ async def test_change_multiple( }, ) external_metadata1 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import1", @@ -2958,8 +2969,8 @@ async def test_change_multiple( "unit_of_measurement": "kWh", } external_metadata2 = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import2", @@ -3340,8 +3351,8 @@ async def test_change_with_none( }, ) external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -3895,8 +3906,8 @@ async def test_get_statistics_service( }, ) external_metadata1 = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import1", @@ -3904,8 +3915,8 @@ async def test_get_statistics_service( "unit_of_measurement": "kWh", } external_metadata2 = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.total_energy_import2", diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 14787301d3eeb4..223c34fa9ae2bf 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -6,6 +6,7 @@ import math from statistics import fmean import sys +from typing import Any from unittest.mock import ANY, patch from _pytest.python_api import ApproxBase @@ -319,8 +320,8 @@ async def test_statistic_during_period( ) imported_metadata = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.test", @@ -1095,8 +1096,8 @@ async def test_statistic_during_period_hole( ] imported_metadata = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy", "source": "recorder", "statistic_id": "sensor.test", @@ -1440,8 +1441,8 @@ async def test_statistic_during_period_partial_overlap( statId = "sensor.test_overlapping" imported_metadata = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": "Total imported energy overlapping", "source": "recorder", "statistic_id": statId, @@ -3550,8 +3551,8 @@ async def test_get_statistics_metadata( }, ) external_energy_metadata_1 = { - "has_mean": has_mean, "has_sum": has_sum, + "mean_type": mean_type, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_gas", @@ -3647,6 +3648,16 @@ async def test_get_statistics_metadata( ] +@pytest.mark.parametrize( + ("external_metadata_extra_2"), + [ + # Neither has_mean nor mean_type interpreted as False/None + {}, + {"has_mean": False}, + # The WS API accepts integer, not enum + {"mean_type": int(StatisticMeanType.NONE)}, + ], +) @pytest.mark.parametrize( ("external_metadata_extra", "unit_1", "unit_2", "unit_3", "expected_unit_class"), [ @@ -3675,6 +3686,7 @@ async def test_import_statistics( hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, external_metadata_extra: dict[str, str], + external_metadata_extra_2: dict[str, Any], unit_1: str, unit_2: str, unit_3: str, @@ -3705,14 +3717,17 @@ async def test_import_statistics( "sum": 3, } - imported_metadata = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": source, - "statistic_id": statistic_id, - "unit_of_measurement": unit_1, - } | external_metadata_extra + imported_metadata = ( + { + "has_sum": True, + "name": "Total imported energy", + "source": source, + "statistic_id": statistic_id, + "unit_of_measurement": unit_1, + } + | external_metadata_extra + | external_metadata_extra_2 + ) await client.send_json_auto_id( { @@ -3997,8 +4012,8 @@ async def test_import_statistics_with_error( } imported_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": int(StatisticMeanType.NONE), "name": "Total imported energy", "source": source, "statistic_id": statistic_id, @@ -4046,6 +4061,15 @@ async def test_import_statistics_with_error( {"unit_class": "energy"}, ], ) +@pytest.mark.parametrize( + ("external_metadata_extra_2"), + [ + {"has_mean": False}, + { + "mean_type": int(StatisticMeanType.NONE) + }, # The WS API accepts integer, not enum + ], +) @pytest.mark.parametrize( ("source", "statistic_id"), [ @@ -4059,6 +4083,7 @@ async def test_adjust_sum_statistics_energy( hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, external_metadata_extra: dict[str, str], + external_metadata_extra_2: dict[str, Any], source, statistic_id, ) -> None: @@ -4085,14 +4110,17 @@ async def test_adjust_sum_statistics_energy( "sum": 3, } - imported_metadata = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": source, - "statistic_id": statistic_id, - "unit_of_measurement": "kWh", - } | external_metadata_extra + imported_metadata = ( + { + "has_sum": True, + "name": "Total imported energy", + "source": source, + "statistic_id": statistic_id, + "unit_of_measurement": "kWh", + } + | external_metadata_extra + | external_metadata_extra_2 + ) await client.send_json_auto_id( { @@ -4250,6 +4278,15 @@ async def test_adjust_sum_statistics_energy( {"unit_class": "volume"}, ], ) +@pytest.mark.parametrize( + ("external_metadata_extra_2"), + [ + {"has_mean": False}, + { + "mean_type": int(StatisticMeanType.NONE) + }, # The WS API accepts integer, not enum + ], +) @pytest.mark.parametrize( ("source", "statistic_id"), [ @@ -4263,6 +4300,7 @@ async def test_adjust_sum_statistics_gas( hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, external_metadata_extra: dict[str, str], + external_metadata_extra_2: dict[str, Any], source, statistic_id, ) -> None: @@ -4289,14 +4327,17 @@ async def test_adjust_sum_statistics_gas( "sum": 3, } - imported_metadata = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": source, - "statistic_id": statistic_id, - "unit_of_measurement": "m³", - } | external_metadata_extra + imported_metadata = ( + { + "has_sum": True, + "name": "Total imported energy", + "source": source, + "statistic_id": statistic_id, + "unit_of_measurement": "m³", + } + | external_metadata_extra + | external_metadata_extra_2 + ) await client.send_json_auto_id( { @@ -4503,8 +4544,8 @@ async def test_adjust_sum_statistics_errors( } imported_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": int(StatisticMeanType.NONE), "name": "Total imported energy", "source": source, "statistic_id": statistic_id, @@ -4667,8 +4708,8 @@ async def test_import_statistics_with_last_reset( } external_metadata = { - "has_mean": False, "has_sum": True, + "mean_type": StatisticMeanType.NONE, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", diff --git a/tests/components/renault/conftest.py b/tests/components/renault/conftest.py index ad968358c784d2..c8e0c83c427494 100644 --- a/tests/components/renault/conftest.py +++ b/tests/components/renault/conftest.py @@ -131,6 +131,11 @@ def _get_fixtures(vehicle_type: str) -> MappingProxyType: if "res_state" in mock_vehicle["endpoints"] else load_fixture("renault/no_data.json") ).get_attributes(schemas.KamereonVehicleResStateDataSchema), + "pressure": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['pressure']}") + if "pressure" in mock_vehicle["endpoints"] + else load_fixture("renault/no_data.json") + ).get_attributes(schemas.KamereonVehicleTyrePressureDataSchema), } @@ -157,6 +162,9 @@ def patch_get_vehicle_data() -> Generator[dict[str, AsyncMock]]: patch( "renault_api.renault_vehicle.RenaultVehicle.get_res_state" ) as get_res_state, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_tyre_pressure" + ) as get_tyre_pressure, ): yield { "battery_status": get_battery_status, @@ -166,6 +174,7 @@ def patch_get_vehicle_data() -> Generator[dict[str, AsyncMock]]: "location": get_location, "lock_status": get_lock_status, "res_state": get_res_state, + "pressure": get_tyre_pressure, } diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 259d1b52f63687..fc2428607d4f44 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -31,6 +31,7 @@ "location": "location.json", "lock_status": "lock_status.1.json", "res_state": "res_state.1.json", + "pressure": "pressure.1.json", }, }, "captur_phev": { @@ -58,6 +59,7 @@ "cockpit": "cockpit_ev.json", "hvac_status": "hvac_status.3.json", "location": "location.json", + "pressure": "pressure.1.json", }, }, } diff --git a/tests/components/renault/fixtures/pressure.1.json b/tests/components/renault/fixtures/pressure.1.json new file mode 100644 index 00000000000000..b4c2d2768656b9 --- /dev/null +++ b/tests/components/renault/fixtures/pressure.1.json @@ -0,0 +1,16 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "flPressure": 2730, + "frPressure": 2790, + "rlPressure": 2340, + "rrPressure": 2460, + "flStatus": 0, + "frStatus": 0, + "rlStatus": 0, + "rrStatus": 0 + } + } +} diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index 3f7c0b637d858b..e7b932104b7e4b 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -3112,6 +3112,118 @@ 'state': '15', }) # --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_front_left_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_front_left_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front left tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'front_left_pressure', + 'unique_id': 'vf1twingoiiivin_front_left_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_front_left_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-TWINGO-III Front left tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_front_left_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2730', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_front_right_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_front_right_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front right tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'front_right_pressure', + 'unique_id': 'vf1twingoiiivin_front_right_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_front_right_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-TWINGO-III Front right tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_front_right_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2790', + }) +# --- # name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_hvac_soc_threshold-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3484,6 +3596,118 @@ 'state': 'unknown', }) # --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_rear_left_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_rear_left_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rear_left_pressure', + 'unique_id': 'vf1twingoiiivin_rear_left_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_rear_left_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-TWINGO-III Rear left tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_rear_left_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2340', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_rear_right_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_rear_right_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rear_right_pressure', + 'unique_id': 'vf1twingoiiivin_rear_right_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_rear_right_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-TWINGO-III Rear right tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_rear_right_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2460', + }) +# --- # name: test_sensors[zoe_40][sensor.reg_zoe_40_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4613,6 +4837,118 @@ 'state': 'unknown', }) # --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_front_left_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_front_left_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front left tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'front_left_pressure', + 'unique_id': 'vf1zoe50vin_front_left_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_front_left_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-ZOE-50 Front left tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_front_left_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2730', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_front_right_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_front_right_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front right tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'front_right_pressure', + 'unique_id': 'vf1zoe50vin_front_right_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_front_right_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-ZOE-50 Front right tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_front_right_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2790', + }) +# --- # name: test_sensors[zoe_50][sensor.reg_zoe_50_hvac_soc_threshold-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4985,3 +5321,115 @@ 'state': 'unplugged', }) # --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_rear_left_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_rear_left_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rear_left_pressure', + 'unique_id': 'vf1zoe50vin_rear_left_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_rear_left_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-ZOE-50 Rear left tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_rear_left_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2340', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_rear_right_tyre_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_rear_right_tyre_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right tyre pressure', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rear_right_pressure', + 'unique_id': 'vf1zoe50vin_rear_right_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_rear_right_tyre_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'REG-ZOE-50 Rear right tyre pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_rear_right_tyre_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2460', + }) +# --- diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index fe2f63331e0748..bfd48222fda164 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -197,7 +197,7 @@ async def test_sensor_throttling_after_init( @pytest.mark.parametrize( ("vehicle_type", "vehicle_count", "scan_interval"), [ - ("zoe_50", 1, 240), # 4 coordinators => 4 minutes interval + ("zoe_50", 1, 300), # 5 coordinators => 5 minutes interval ("captur_fuel", 1, 180), # 3 coordinators => 3 minutes interval ("multi", 2, 420), # 7 coordinators => 8 minutes interval ], @@ -236,7 +236,7 @@ async def test_dynamic_scan_interval( @pytest.mark.parametrize( ("vehicle_type", "vehicle_count", "scan_interval"), [ - ("zoe_50", 1, 180), # (6-1) coordinators => 3 minutes interval + ("zoe_50", 1, 240), # (7-1) coordinators => 4 minutes interval ("captur_fuel", 1, 180), # (4-1) coordinators => 3 minutes interval ("multi", 2, 360), # (8-2) coordinators => 6 minutes interval ], diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 9f82b5fe6081d2..cfb74b563a844b 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -263,6 +263,7 @@ async def assert_validation_result( ("distance", "mi", "mi", "mi", "distance", 13.050847, -10, 30), ("humidity", "%", "%", "%", "unitless", 13.050847, -10, 30), ("humidity", None, None, None, "unitless", 13.050847, -10, 30), + ("pressure", "mPa", "mPa", "mPa", "pressure", 13.050847, -10, 30), ("pressure", "Pa", "Pa", "Pa", "pressure", 13.050847, -10, 30), ("pressure", "hPa", "hPa", "hPa", "pressure", 13.050847, -10, 30), ("pressure", "mbar", "mbar", "mbar", "pressure", 13.050847, -10, 30), @@ -2601,6 +2602,7 @@ async def test_compile_hourly_energy_statistics_multiple( ("distance", "mi", 30), ("humidity", "%", 30), ("humidity", None, 30), + ("pressure", "mPa", 30), ("pressure", "Pa", 30), ("pressure", "hPa", 30), ("pressure", "mbar", 30), @@ -2767,6 +2769,7 @@ async def test_compile_hourly_statistics_partially_unavailable( ("distance", "mi", 30), ("humidity", "%", 30), ("humidity", None, 30), + ("pressure", "mPa", 30), ("pressure", "Pa", 30), ("pressure", "hPa", 30), ("pressure", "mbar", 30), @@ -3045,6 +3048,15 @@ async def test_compile_hourly_statistics_fails( "volume", StatisticMeanType.ARITHMETIC, ), + ( + "measurement", + "pressure", + "mPa", + "mPa", + "mPa", + "pressure", + StatisticMeanType.ARITHMETIC, + ), ( "measurement", "pressure", @@ -5125,7 +5137,7 @@ def set_state(entity_id, state, **kwargs): "pressure", "psi", "bar", - "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, @@ -5133,7 +5145,7 @@ def set_state(entity_id, state, **kwargs): "pressure", "Pa", "bar", - "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mPa, mbar, mmHg, psi", ), ], ) @@ -5364,7 +5376,7 @@ async def test_validate_statistics_unit_ignore_device_class( "pressure", "psi", "bar", - "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, @@ -5372,7 +5384,7 @@ async def test_validate_statistics_unit_ignore_device_class( "pressure", "Pa", "bar", - "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, @@ -6141,8 +6153,8 @@ async def test_validate_statistics_other_domain( # Create statistics for another domain metadata: StatisticMetaData = { - "has_mean": True, "has_sum": True, + "mean_type": StatisticMeanType.ARITHMETIC, "name": None, "source": RECORDER_DOMAIN, "statistic_id": "number.test", diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index f709d4291adf93..57fa8bec950318 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -617,6 +617,13 @@ def initialized(): {}, RpcUpdateType.INITIALIZED ) + current_pos = iter(range(50, -1, -10)) # from 50 to 0 in steps of 10 + + async def update_cover_status(cover_id: int): + device.status[f"cover:{cover_id}"]["current_pos"] = next( + current_pos, device.status[f"cover:{cover_id}"]["current_pos"] + ) + device = _mock_rpc_device() rpc_device_mock.return_value = device rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=disconnected) @@ -624,6 +631,9 @@ def initialized(): rpc_device_mock.return_value.mock_event = Mock(side_effect=event) rpc_device_mock.return_value.mock_online = Mock(side_effect=online) rpc_device_mock.return_value.mock_initialized = Mock(side_effect=initialized) + rpc_device_mock.return_value.update_cover_status = AsyncMock( + side_effect=update_cover_status + ) yield rpc_device_mock.return_value diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index 63dac548ed1548..188660a6346efe 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -139,6 +139,8 @@ async def test_rpc_device_services( {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, blocking=True, ) + + mock_rpc_device.cover_set_position.assert_called_once_with(0, pos=50) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_POSITION] == 50 @@ -153,6 +155,7 @@ async def test_rpc_device_services( ) mock_rpc_device.mock_update() + mock_rpc_device.cover_open.assert_called_once_with(0) assert (state := hass.states.get(entity_id)) assert state.state == CoverState.OPENING @@ -167,6 +170,7 @@ async def test_rpc_device_services( ) mock_rpc_device.mock_update() + mock_rpc_device.cover_close.assert_called_once_with(0) assert (state := hass.states.get(entity_id)) assert state.state == CoverState.CLOSING @@ -178,6 +182,8 @@ async def test_rpc_device_services( blocking=True, ) mock_rpc_device.mock_update() + + mock_rpc_device.cover_stop.assert_called_once_with(0) assert (state := hass.states.get(entity_id)) assert state.state == CoverState.CLOSED @@ -262,9 +268,11 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 50) mock_rpc_device.mock_update() + mock_rpc_device.cover_set_position.assert_called_once_with(0, slat_pos=50) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + mock_rpc_device.cover_set_position.reset_mock() await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER_TILT, @@ -274,9 +282,11 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 100) mock_rpc_device.mock_update() + mock_rpc_device.cover_set_position.assert_called_once_with(0, slat_pos=100) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + mock_rpc_device.cover_set_position.reset_mock() await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER_TILT, @@ -292,157 +302,78 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 10) mock_rpc_device.mock_update() + mock_rpc_device.cover_stop.assert_called_once_with(0) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 -async def test_update_position_closing( +async def test_rpc_cover_position_update( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test update_position while the cover is closing.""" + """Test RPC update_position while the cover is moving.""" entity_id = "cover.test_name_test_cover_0" await init_integration(hass, 2) - # Set initial state to closing + # Set initial state to closing, position 50 set by update_cover_status mock mutate_rpc_device_status( monkeypatch, mock_rpc_device, "cover:0", "state", "closing" ) - mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "current_pos", 40) mock_rpc_device.mock_update() assert (state := hass.states.get(entity_id)) assert state.state == CoverState.CLOSING - assert state.attributes[ATTR_CURRENT_POSITION] == 40 - - # Simulate position decrement - async def simulated_update(*args, **kwargs): - pos = mock_rpc_device.status["cover:0"]["current_pos"] - if pos > 0: - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "current_pos", pos - 10 - ) - else: - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "current_pos", 0 - ) - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "state", "closed" - ) - - # Patching the mock update_status method - monkeypatch.setattr(mock_rpc_device, "update_status", simulated_update) + assert state.attributes[ATTR_CURRENT_POSITION] == 50 # Simulate position updates during closing for position in range(40, -1, -10): - assert (state := hass.states.get(entity_id)) - assert state.attributes[ATTR_CURRENT_POSITION] == position - assert state.state == CoverState.CLOSING + mock_rpc_device.update_cover_status.reset_mock() await mock_polling_rpc_update(hass, freezer, RPC_COVER_UPDATE_TIME_SEC) - # Final state should be closed - assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.CLOSED - assert state.attributes[ATTR_CURRENT_POSITION] == 0 - - -async def test_update_position_opening( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_rpc_device: Mock, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test update_position while the cover is opening.""" - entity_id = "cover.test_name_test_cover_0" - await init_integration(hass, 2) - - # Set initial state to opening at 60 - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "state", "opening" - ) - mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "current_pos", 60) - mock_rpc_device.mock_update() - - assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.OPENING - assert state.attributes[ATTR_CURRENT_POSITION] == 60 - - # Simulate position increment - async def simulated_update(*args, **kwargs): - pos = mock_rpc_device.status["cover:0"]["current_pos"] - if pos < 100: - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "current_pos", pos + 10 - ) - else: - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "current_pos", 100 - ) - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "state", "open" - ) - - # Patching the mock update_status method - monkeypatch.setattr(mock_rpc_device, "update_status", simulated_update) - - # Check position updates during opening - for position in range(60, 101, 10): + mock_rpc_device.update_cover_status.assert_called_once_with(0) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_POSITION] == position - assert state.state == CoverState.OPENING - await mock_polling_rpc_update(hass, freezer, RPC_COVER_UPDATE_TIME_SEC) - - # Final state should be open - assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 - - -async def test_update_position_no_movement( - hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test update_position when the cover is not moving.""" - entity_id = "cover.test_name_test_cover_0" - await init_integration(hass, 2) + assert state.state == CoverState.CLOSING - # Set initial state to open - mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "open") - mutate_rpc_device_status( - monkeypatch, mock_rpc_device, "cover:0", "current_pos", 100 - ) + # Simulate cover reaching final position + mock_rpc_device.update_cover_status.reset_mock() + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "closed") mock_rpc_device.mock_update() assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 - - # Call update_position and ensure no changes occur - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + assert state.state == CoverState.CLOSED - assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 + # Ensure update_position does not call update_cover_status when the cover is not moving + await mock_polling_rpc_update(hass, freezer, RPC_COVER_UPDATE_TIME_SEC) + mock_rpc_device.update_cover_status.assert_not_called() async def test_rpc_not_initialized_update( - hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + freezer: FrozenDateTimeFactory, ) -> None: """Test update not called when device is not initialized.""" entity_id = "cover.test_name_test_cover_0" await init_integration(hass, 2) - assert (state := hass.states.get(entity_id)) - assert state.state == CoverState.OPEN + # Set initial state to closing + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "state", "closing" + ) + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "current_pos", 40) + # mock device not initialized (e.g. disconnected) monkeypatch.setattr(mock_rpc_device, "initialized", False) mock_rpc_device.mock_update() + # wait for update interval to allow update_position to call update_cover_status + await mock_polling_rpc_update(hass, freezer, RPC_COVER_UPDATE_TIME_SEC) + + mock_rpc_device.update_cover_status.assert_not_called() assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/unifi/snapshots/test_light.ambr b/tests/components/unifi/snapshots/test_light.ambr new file mode 100644 index 00000000000000..fc9d972f9be3e9 --- /dev/null +++ b/tests/components/unifi/snapshots/test_light.ambr @@ -0,0 +1,72 @@ +# serializer version: 1 +# name: test_light_platform_snapshot[device_payload0][light.device_with_led_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.device_with_led_led', + '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': 'LED', + 'platform': 'unifi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led_control', + 'unique_id': 'led-10:00:00:00:01:01', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_platform_snapshot[device_payload0][light.device_with_led_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 204, + 'color_mode': , + 'friendly_name': 'Device with LED LED', + 'hs_color': tuple( + 240.0, + 100.0, + ), + 'rgb_color': tuple( + 0, + 0, + 255, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.136, + 0.04, + ), + }), + 'context': , + 'entity_id': 'light.device_with_led_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index 897eab2ae12b07..78f9d484619c10 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -41,6 +41,7 @@ async def test_hub_setup( Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.IMAGE, + Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/tests/components/unifi/test_light.py b/tests/components/unifi/test_light.py new file mode 100644 index 00000000000000..6ee40c9a91d038 --- /dev/null +++ b/tests/components/unifi/test_light.py @@ -0,0 +1,323 @@ +"""UniFi Network light platform tests.""" + +from copy import deepcopy +from unittest.mock import patch + +from aiounifi.models.message import MessageKey +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.unifi.const import CONF_SITE_ID +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + ConfigEntryFactoryType, + WebsocketMessageMock, + WebsocketStateManager, +) + +from tests.common import MockConfigEntry, snapshot_platform +from tests.test_util.aiohttp import AiohttpClientMocker + +DEVICE_WITH_LED = { + "board_rev": 3, + "device_id": "mock-id", + "ip": "10.0.0.1", + "last_seen": 1562600145, + "mac": "10:00:00:00:01:01", + "model": "U6-Lite", + "name": "Device with LED", + "next_interval": 20, + "state": 1, + "type": "uap", + "version": "4.0.42.10433", + "led_override": "on", + "led_override_color": "#0000ff", + "led_override_color_brightness": 80, + "hw_caps": 2, +} + +DEVICE_WITHOUT_LED = { + "board_rev": 2, + "device_id": "mock-id-2", + "ip": "10.0.0.2", + "last_seen": 1562600145, + "mac": "10:00:00:00:01:02", + "model": "US-8-60W", + "name": "Device without LED", + "next_interval": 20, + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + "hw_caps": 0, +} + +DEVICE_LED_OFF = { + "board_rev": 3, + "device_id": "mock-id-3", + "ip": "10.0.0.3", + "last_seen": 1562600145, + "mac": "10:00:00:00:01:03", + "model": "U6-Pro", + "name": "Device LED Off", + "next_interval": 20, + "state": 1, + "type": "uap", + "version": "4.0.42.10433", + "led_override": "off", + "led_override_color": "#ffffff", + "led_override_color_brightness": 0, + "hw_caps": 2, +} + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED, DEVICE_WITHOUT_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_lights( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test lights.""" + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + assert light_entity.attributes["brightness"] == 204 + assert light_entity.attributes["rgb_color"] == (0, 0, 255) + + assert hass.states.get("light.device_without_led_led") is None + + +@pytest.mark.parametrize("device_payload", [[DEVICE_LED_OFF]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_off_state( + hass: HomeAssistant, +) -> None: + """Test light off state.""" + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + + light_entity = hass.states.get("light.device_led_off_led") + assert light_entity is not None + assert light_entity.state == STATE_OFF + assert light_entity.attributes.get("brightness") is None + assert light_entity.attributes.get("rgb_color") is None + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_turn_on_off( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test turn on and off.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.device_with_led_led"}, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "off" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.device_with_led_led"}, + blocking=True, + ) + + assert aioclient_mock.call_count == 2 + call_data = aioclient_mock.mock_calls[1][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_set_brightness( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test set brightness.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.device_with_led_led", + ATTR_BRIGHTNESS: 127, + }, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_set_rgb_color( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test set RGB color.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.device_with_led_led", + ATTR_RGB_COLOR: (255, 0, 0), + }, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_set_brightness_and_color( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test set brightness and color.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.device_with_led_led", + ATTR_RGB_COLOR: (0, 255, 0), + ATTR_BRIGHTNESS: 191, + }, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_state_update_via_websocket( + hass: HomeAssistant, + mock_websocket_message: WebsocketMessageMock, +) -> None: + """Test state update via websocket.""" + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + assert light_entity.attributes["rgb_color"] == (0, 0, 255) + updated_device = deepcopy(DEVICE_WITH_LED) + updated_device["led_override"] = "off" + updated_device["led_override_color"] = "#ff0000" + updated_device["led_override_color_brightness"] = 100 + + mock_websocket_message(message=MessageKey.DEVICE, data=[updated_device]) + await hass.async_block_till_done() + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_OFF + assert light_entity.attributes.get("rgb_color") is None + assert light_entity.attributes.get("brightness") is None + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_device_offline( + hass: HomeAssistant, + mock_websocket_message: WebsocketMessageMock, +) -> None: + """Test device offline.""" + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + assert hass.states.get("light.device_with_led_led") is not None + + offline_device = deepcopy(DEVICE_WITH_LED) + offline_device["state"] = 0 + mock_websocket_message(message=MessageKey.DEVICE, data=[offline_device]) + await hass.async_block_till_done() + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_device_unavailable( + hass: HomeAssistant, + mock_websocket_state: WebsocketStateManager, +) -> None: + """Test device unavailable.""" + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + + updated_device = deepcopy(DEVICE_WITH_LED) + updated_device["state"] = 0 + + await mock_websocket_state.disconnect() + await hass.async_block_till_done() + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +async def test_light_platform_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry_factory: ConfigEntryFactoryType, + snapshot: SnapshotAssertion, +) -> None: + """Test platform snapshot.""" + with patch("homeassistant.components.unifi.PLATFORMS", [Platform.LIGHT]): + config_entry = await config_entry_factory() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index daacddb32675dd..bd7243347cd72b 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -82,7 +82,6 @@ 'display_status': 'on', 'friendly_name': 'Air Purifier 131s', 'mode': 'sleep', - 'night_light': None, 'percentage': None, 'percentage_step': 33.333333333333336, 'preset_mode': 'sleep', @@ -182,7 +181,7 @@ 'display_status': 'on', 'friendly_name': 'Air Purifier 200s', 'mode': 'manual', - 'night_light': , + 'night_light': 'off', 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, @@ -282,7 +281,7 @@ 'display_status': 'on', 'friendly_name': 'Air Purifier 400s', 'mode': 'manual', - 'night_light': , + 'night_light': 'off', 'percentage': 25, 'percentage_step': 25.0, 'preset_mode': None, @@ -383,7 +382,7 @@ 'display_status': 'on', 'friendly_name': 'Air Purifier 600s', 'mode': 'manual', - 'night_light': , + 'night_light': 'off', 'percentage': 25, 'percentage_step': 25.0, 'preset_mode': None, diff --git a/tests/components/vodafone_station/conftest.py b/tests/components/vodafone_station/conftest.py index 778d8fdaa41bd8..4790ddbe0b8d19 100644 --- a/tests/components/vodafone_station/conftest.py +++ b/tests/components/vodafone_station/conftest.py @@ -2,13 +2,28 @@ from datetime import UTC, datetime -from aiovodafone import VodafoneStationDevice +from aiovodafone.api import VodafoneStationCommonApi, VodafoneStationDevice import pytest +from yarl import URL -from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.components.vodafone_station.const import ( + CONF_DEVICE_DETAILS, + DEVICE_TYPE, + DEVICE_URL, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from .const import DEVICE_1_HOST, DEVICE_1_MAC, DEVICE_2_MAC +from .const import ( + DEVICE_1_HOST, + DEVICE_1_MAC, + DEVICE_2_MAC, + TEST_HOST, + TEST_PASSWORD, + TEST_TYPE, + TEST_URL, + TEST_USERNAME, +) from tests.common import ( AsyncMock, @@ -34,53 +49,71 @@ def mock_vodafone_station_router() -> Generator[AsyncMock]: """Mock a Vodafone Station router.""" with ( patch( - "homeassistant.components.vodafone_station.coordinator.VodafoneStationSercommApi", + "homeassistant.components.vodafone_station.coordinator.init_api_class", autospec=True, ) as mock_router, patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi", + "homeassistant.components.vodafone_station.config_flow.init_api_class", new=mock_router, ), + patch.object( + VodafoneStationCommonApi, + "get_device_type", + new=AsyncMock(return_value=(TEST_TYPE, URL(TEST_URL))), + ), ): router = mock_router.return_value - router.get_devices_data.return_value = { - DEVICE_1_MAC: VodafoneStationDevice( - connected=True, - connection_type="wifi", - ip_address="192.168.1.10", - name=DEVICE_1_HOST, - mac=DEVICE_1_MAC, - type="laptop", - wifi="2.4G", - ), - DEVICE_2_MAC: VodafoneStationDevice( - connected=False, - connection_type="lan", - ip_address="192.168.1.11", - name="LanDevice1", - mac=DEVICE_2_MAC, - type="desktop", - wifi="", - ), - } - router.get_sensor_data.return_value = load_json_object_fixture( - "get_sensor_data.json", DOMAIN + router.login = AsyncMock(return_value=True) + router.logout = AsyncMock(return_value=True) + router.get_devices_data = AsyncMock( + return_value={ + DEVICE_1_MAC: VodafoneStationDevice( + connected=True, + connection_type="wifi", + ip_address="192.168.1.10", + name=DEVICE_1_HOST, + mac=DEVICE_1_MAC, + type="laptop", + wifi="2.4G", + ), + DEVICE_2_MAC: VodafoneStationDevice( + connected=False, + connection_type="lan", + ip_address="192.168.1.11", + name="LanDevice1", + mac=DEVICE_2_MAC, + type="desktop", + wifi="", + ), + } + ) + router.get_sensor_data = AsyncMock( + return_value=load_json_object_fixture("get_sensor_data.json", DOMAIN) ) router.convert_uptime.return_value = datetime( 2024, 11, 19, 20, 19, 0, tzinfo=UTC ) - router.base_url = "https://fake_host" + router.base_url = URL(TEST_URL) + router.restart_connection = AsyncMock(return_value=True) + router.restart_router = AsyncMock(return_value=True) + yield router @pytest.fixture -def mock_config_entry() -> Generator[MockConfigEntry]: +def mock_config_entry() -> MockConfigEntry: """Mock a Vodafone Station config entry.""" return MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: TEST_TYPE, + DEVICE_URL: TEST_URL, + }, }, + version=1, + minor_version=2, ) diff --git a/tests/components/vodafone_station/const.py b/tests/components/vodafone_station/const.py index cf6c274e5d5940..744430e06f8fc0 100644 --- a/tests/components/vodafone_station/const.py +++ b/tests/components/vodafone_station/const.py @@ -4,3 +4,9 @@ DEVICE_1_MAC = "xx:xx:xx:xx:xx:xx" DEVICE_2_HOST = "LanDevice1" DEVICE_2_MAC = "yy:yy:yy:yy:yy:yy" + +TEST_HOST = "fake_host" +TEST_PASSWORD = "fake_password" +TEST_TYPE = "Sercomm" +TEST_URL = f"https://{TEST_HOST}" +TEST_USERNAME = "fake_username" diff --git a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr index be2956e0aab714..6929d875dfd571 100644 --- a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr +++ b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr @@ -27,6 +27,10 @@ }), 'entry': dict({ 'data': dict({ + 'device_details': dict({ + 'device_type': 'Sercomm', + 'device_url': 'https://fake_host', + }), 'host': 'fake_host', 'password': '**REDACTED**', 'username': '**REDACTED**', @@ -35,7 +39,7 @@ 'discovery_keys': dict({ }), 'domain': 'vodafone_station', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 4653230f7ca94a..9d9ed2fda85e62 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -11,12 +11,19 @@ import pytest from homeassistant.components.device_tracker import CONF_CONSIDER_HOME -from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.components.vodafone_station.const import ( + CONF_DEVICE_DETAILS, + DEVICE_TYPE, + DEVICE_URL, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .const import TEST_HOST, TEST_PASSWORD, TEST_TYPE, TEST_URL, TEST_USERNAME + from tests.common import MockConfigEntry @@ -35,16 +42,20 @@ async def test_user( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: TEST_TYPE, + DEVICE_URL: TEST_URL, + }, } assert not result["result"].unique_id @@ -81,9 +92,9 @@ async def test_exception_connection( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, }, ) @@ -96,18 +107,22 @@ async def test_exception_connection( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "fake_host" + assert result["title"] == TEST_HOST assert result["data"] == { - "host": "fake_host", - "username": "fake_username", - "password": "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: TEST_TYPE, + DEVICE_URL: TEST_URL, + }, } @@ -127,9 +142,9 @@ async def test_duplicate_entry( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, }, ) assert result["type"] is FlowResultType.ABORT @@ -199,13 +214,13 @@ async def test_reauth_not_successful( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_PASSWORD: "fake_password", + CONF_PASSWORD: TEST_PASSWORD, }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert mock_config_entry.data[CONF_PASSWORD] == "fake_password" + assert mock_config_entry.data[CONF_PASSWORD] == TEST_PASSWORD async def test_options_flow( @@ -244,7 +259,7 @@ async def test_reconfigure_successful( assert result["step_id"] == "reconfigure" # original entry - assert mock_config_entry.data["host"] == "fake_host" + assert mock_config_entry.data[CONF_HOST] == TEST_HOST new_host = "192.168.100.60" @@ -252,8 +267,8 @@ async def test_reconfigure_successful( result["flow_id"], user_input={ CONF_HOST: new_host, - CONF_PASSWORD: "fake_password", - CONF_USERNAME: "fake_username", + CONF_PASSWORD: TEST_PASSWORD, + CONF_USERNAME: TEST_USERNAME, }, ) @@ -261,7 +276,7 @@ async def test_reconfigure_successful( assert reconfigure_result["reason"] == "reconfigure_successful" # changed entry - assert mock_config_entry.data["host"] == new_host + assert mock_config_entry.data[CONF_HOST] == new_host @pytest.mark.parametrize( @@ -294,8 +309,8 @@ async def test_reconfigure_fails( result["flow_id"], user_input={ CONF_HOST: "192.168.100.60", - CONF_PASSWORD: "fake_password", - CONF_USERNAME: "fake_username", + CONF_PASSWORD: TEST_PASSWORD, + CONF_USERNAME: TEST_USERNAME, }, ) @@ -309,8 +324,8 @@ async def test_reconfigure_fails( result["flow_id"], user_input={ CONF_HOST: "192.168.100.61", - CONF_PASSWORD: "fake_password", - CONF_USERNAME: "fake_username", + CONF_PASSWORD: TEST_PASSWORD, + CONF_USERNAME: TEST_USERNAME, }, ) @@ -318,6 +333,10 @@ async def test_reconfigure_fails( assert reconfigure_result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { CONF_HOST: "192.168.100.61", - CONF_PASSWORD: "fake_password", - CONF_USERNAME: "fake_username", + CONF_PASSWORD: TEST_PASSWORD, + CONF_USERNAME: TEST_USERNAME, + CONF_DEVICE_DETAILS: { + DEVICE_TYPE: TEST_TYPE, + DEVICE_URL: TEST_URL, + }, } diff --git a/tests/components/vodafone_station/test_init.py b/tests/components/vodafone_station/test_init.py index 053f0a95fe46a2..9813a25fccc6e2 100644 --- a/tests/components/vodafone_station/test_init.py +++ b/tests/components/vodafone_station/test_init.py @@ -3,12 +3,19 @@ from unittest.mock import AsyncMock from homeassistant.components.device_tracker import CONF_CONSIDER_HOME -from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.components.vodafone_station.const import ( + CONF_DEVICE_DETAILS, + DEVICE_TYPE, + DEVICE_URL, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import setup_integration +from .const import TEST_HOST, TEST_PASSWORD, TEST_TYPE, TEST_URL, TEST_USERNAME from tests.common import MockConfigEntry @@ -51,3 +58,35 @@ async def test_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + + +async def test_migrate_entry( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful migration of entry data.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + title=TEST_HOST, + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id="vodafone", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.minor_version == 2 + assert config_entry.data[CONF_DEVICE_DETAILS] == { + DEVICE_TYPE: TEST_TYPE, + DEVICE_URL: TEST_URL, + } diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py index 4e2e129d4be140..d23b107a7d4f9c 100644 --- a/tests/components/voip/test_devices.py +++ b/tests/components/voip/test_devices.py @@ -2,11 +2,15 @@ from __future__ import annotations +from unittest.mock import AsyncMock + import pytest from voip_utils import CallInfo +from voip_utils.sip import SipEndpoint from homeassistant.components.voip import DOMAIN from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices +from homeassistant.components.voip.store import VoipStore from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -63,6 +67,72 @@ async def test_device_registry_info_from_unknown_phone( assert device.sw_version is None +async def test_device_registry_info_update_contact( + hass: HomeAssistant, + voip_devices: VoIPDevices, + call_info: CallInfo, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry.""" + voip_device = voip_devices.async_get_or_create(call_info) + assert not voip_device.async_allow_call(hass) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} + ) + assert device is not None + assert device.name == call_info.caller_endpoint.host + assert device.manufacturer == "Grandstream" + assert device.model == "HT801" + assert device.sw_version == "1.0.17.5" + + # Test we update the device if the fw updates + call_info.headers["user-agent"] = "Grandstream HT801 2.0.0.0" + call_info.contact_endpoint = SipEndpoint("Test ") + voip_device = voip_devices.async_get_or_create(call_info) + + assert voip_device.contact == SipEndpoint("Test ") + assert not voip_device.async_allow_call(hass) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} + ) + assert device.sw_version == "2.0.0.0" + + +async def test_device_load_contact( + hass: HomeAssistant, + call_info: CallInfo, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test loading contact endpoint from Store.""" + voip_id = call_info.caller_endpoint.uri + mock_store = VoipStore(hass, "test") + mock_store.async_load = AsyncMock( + return_value={voip_id: {"contact": "Test "}} + ) + + config_entry.runtime_data = mock_store + + # Initialize voip device + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, voip_id)}, + name=call_info.caller_endpoint.host, + manufacturer="Grandstream", + model="HT801", + sw_version="1.0.0.0", + configuration_url=f"http://{call_info.caller_ip}", + ) + + voip = VoIPDevices(hass, config_entry) + + await voip.async_setup() + voip_device = voip.devices.get(voip_id) + assert voip_device.contact == SipEndpoint("Test ") + + async def test_remove_device_registry_entry( hass: HomeAssistant, voip_device: VoIPDevice, diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 4a62ae99817f1d..480f44c0ff1e07 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -32,7 +32,12 @@ from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.core import Context, HomeAssistant, State, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + label_registry as lr, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_state_change_event from homeassistant.loader import Integration, async_get_integration @@ -108,6 +113,29 @@ def _apply_entities_changes(state_dict: dict, change_dict: dict) -> None: del state_dict[STATE_KEY_LONG_NAMES[key]][item] +def _assert_extract_from_target_command_result( + msg: dict[str, Any], + entities: set[str] | None = None, + devices: set[str] | None = None, + areas: set[str] | None = None, + missing_devices: set[str] | None = None, + missing_areas: set[str] | None = None, + missing_labels: set[str] | None = None, + missing_floors: set[str] | None = None, +) -> None: + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + result = msg["result"] + assert set(result["referenced_entities"]) == (entities or set()) + assert set(result["referenced_devices"]) == (devices or set()) + assert set(result["referenced_areas"]) == (areas or set()) + assert set(result["missing_devices"]) == (missing_devices or set()) + assert set(result["missing_areas"]) == (missing_areas or set()) + assert set(result["missing_floors"]) == (missing_floors or set()) + assert set(result["missing_labels"]) == (missing_labels or set()) + + async def test_fire_event( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: @@ -3223,3 +3251,236 @@ async def mock_setup(hass: HomeAssistant, _) -> bool: # The component has been loaded assert "test" in hass.config.components + + +async def test_extract_from_target( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + label_registry: lr.LabelRegistry, +) -> None: + """Test extract_from_target command with mixed target types including entities, devices, areas, and labels.""" + + async def call_command(target: dict[str, str]) -> Any: + await websocket_client.send_json_auto_id( + {"type": "extract_from_target", "target": target} + ) + return await websocket_client.receive_json() + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + device1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device1")}, + ) + + device2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device2")}, + ) + + area_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device3")}, + ) + + label2_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device4")}, + ) + + kitchen_area = area_registry.async_create("Kitchen") + living_room_area = area_registry.async_create("Living Room") + label_area = area_registry.async_create("Bathroom") + label1 = label_registry.async_create("Test Label 1") + label2 = label_registry.async_create("Test Label 2") + + # Associate devices with areas and labels + device_registry.async_update_device(area_device.id, area_id=kitchen_area.id) + device_registry.async_update_device(label2_device.id, labels={label2.label_id}) + area_registry.async_update(label_area.id, labels={label1.label_id}) + + # Setup entities with targets + device1_entity1 = entity_registry.async_get_or_create( + "light", "test", "unique1", device_id=device1.id + ) + device1_entity2 = entity_registry.async_get_or_create( + "switch", "test", "unique2", device_id=device1.id + ) + device2_entity = entity_registry.async_get_or_create( + "sensor", "test", "unique3", device_id=device2.id + ) + area_device_entity = entity_registry.async_get_or_create( + "light", "test", "unique4", device_id=area_device.id + ) + area_entity = entity_registry.async_get_or_create("switch", "test", "unique5") + label_device_entity = entity_registry.async_get_or_create( + "light", "test", "unique6", device_id=label2_device.id + ) + label_entity = entity_registry.async_get_or_create("switch", "test", "unique7") + + # Associate entities with areas and labels + entity_registry.async_update_entity( + area_entity.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity( + label_entity.entity_id, labels={label1.label_id} + ) + + msg = await call_command({"entity_id": ["light.unknown_entity"]}) + _assert_extract_from_target_command_result(msg, entities={"light.unknown_entity"}) + + msg = await call_command({"device_id": [device1.id, device2.id]}) + _assert_extract_from_target_command_result( + msg, + entities={ + device1_entity1.entity_id, + device1_entity2.entity_id, + device2_entity.entity_id, + }, + devices={device1.id, device2.id}, + ) + + msg = await call_command({"area_id": [kitchen_area.id, living_room_area.id]}) + _assert_extract_from_target_command_result( + msg, + entities={area_device_entity.entity_id, area_entity.entity_id}, + areas={kitchen_area.id, living_room_area.id}, + devices={area_device.id}, + ) + + msg = await call_command({"label_id": [label1.label_id, label2.label_id]}) + _assert_extract_from_target_command_result( + msg, + entities={label_device_entity.entity_id, label_entity.entity_id}, + devices={label2_device.id}, + areas={label_area.id}, + ) + + # Test multiple mixed targets + msg = await call_command( + { + "entity_id": ["light.direct"], + "device_id": [device1.id], + "area_id": [kitchen_area.id], + "label_id": [label1.label_id], + }, + ) + _assert_extract_from_target_command_result( + msg, + entities={ + "light.direct", + device1_entity1.entity_id, + device1_entity2.entity_id, + area_device_entity.entity_id, + label_entity.entity_id, + }, + devices={device1.id, area_device.id}, + areas={kitchen_area.id, label_area.id}, + ) + + +async def test_extract_from_target_expand_group( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: + """Test extract_from_target command with expand_group parameter.""" + await async_setup_component( + hass, + "group", + { + "group": { + "test_group": { + "name": "Test Group", + "entities": ["light.kitchen", "light.living_room"], + } + } + }, + ) + + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.living_room", "off") + + # Test without expand_group (default False) + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {"entity_id": ["group.test_group"]}, + } + ) + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result(msg, entities={"group.test_group"}) + + # Test with expand_group=True + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {"entity_id": ["group.test_group"]}, + "expand_group": True, + } + ) + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result( + msg, + entities={"light.kitchen", "light.living_room"}, + ) + + +async def test_extract_from_target_missing_entities( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: + """Test extract_from_target command with missing device IDs, area IDs, etc.""" + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": { + "device_id": ["non_existent_device"], + "area_id": ["non_existent_area"], + "label_id": ["non_existent_label"], + }, + } + ) + + msg = await websocket_client.receive_json() + # Non-existent devices/areas are still referenced but reported as missing + _assert_extract_from_target_command_result( + msg, + devices={"non_existent_device"}, + areas={"non_existent_area"}, + missing_areas={"non_existent_area"}, + missing_devices={"non_existent_device"}, + missing_labels={"non_existent_label"}, + ) + + +async def test_extract_from_target_empty_target( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: + """Test extract_from_target command with empty target.""" + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": {}, + } + ) + + msg = await websocket_client.receive_json() + _assert_extract_from_target_command_result(msg) + + +async def test_extract_from_target_validation_error( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: + """Test extract_from_target command with invalid target data.""" + await websocket_client.send_json_auto_id( + { + "type": "extract_from_target", + "target": "invalid", # Should be a dict, not string + } + ) + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert "error" in msg diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index 99e205e91b9be4..6325905fa0a379 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -1,11 +1,19 @@ """Tests for the WLED select platform.""" +import typing from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError +from wled import ( + Device as WLEDDevice, + Palette as WLEDPalette, + Playlist as WLEDPlaylist, + Preset as WLEDPreset, + WLEDConnectionError, + WLEDError, +) from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.components.wled.const import DOMAIN, SCAN_INTERVAL @@ -168,3 +176,79 @@ async def test_playlist_unavailable_without_playlists(hass: HomeAssistant) -> No """Test WLED playlist entity is unavailable when playlists are not available.""" assert (state := hass.states.get("select.wled_rgb_light_playlist")) assert state.state == STATE_UNAVAILABLE + + +PLAYLIST = {"ps": [1], "dur": [100], "transition": [7], "repeat": 0, "end": 0, "r": 0} + + +@pytest.mark.parametrize( + ("entity_id", "data_attr", "new_data", "new_options"), + [ + ( + "select.wled_rgb_light_preset", + "presets", + { + 1: WLEDPreset.from_dict({"preset_id": 1, "n": "Preset 1"}), + 2: WLEDPreset.from_dict({"preset_id": 2, "n": "Preset 2"}), + }, + ["Preset 1", "Preset 2"], + ), + ( + "select.wled_rgb_light_playlist", + "playlists", + { + 1: WLEDPlaylist.from_dict( + {"playlist_id": 1, "n": "Playlist 1", "playlist": PLAYLIST} + ), + 2: WLEDPlaylist.from_dict( + {"playlist_id": 2, "n": "Playlist 2", "playlist": PLAYLIST} + ), + }, + ["Playlist 1", "Playlist 2"], + ), + ( + "select.wled_rgb_light_color_palette", + "palettes", + { + 0: WLEDPalette.from_dict({"palette_id": 0, "name": "Palette 1"}), + 1: WLEDPalette.from_dict({"palette_id": 1, "name": "Palette 2"}), + }, + ["Palette 1", "Palette 2"], + ), + ], +) +async def test_select_load_new_options_after_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_wled: MagicMock, + entity_id: str, + data_attr: str, + new_data: typing.Any, + new_options: list[str], +) -> None: + """Test WLED select entity is updated when new options are added.""" + setattr( + mock_wled.update.return_value, + data_attr, + {}, + ) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.attributes["options"] == [] + + setattr( + mock_wled.update.return_value, + data_attr, + new_data, + ) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.attributes["options"] == new_options diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index c81128ac8bb1fd..d91a53fffe914a 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -1,5 +1,6 @@ """Tests for the update coordinator.""" +import asyncio from datetime import datetime, timedelta import logging from unittest.mock import AsyncMock, Mock, patch @@ -405,6 +406,70 @@ async def test_update_interval_not_present( assert crd.data is None +async def test_update_locks( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: + """Test update interval works.""" + start = asyncio.Event() + block = asyncio.Event() + + async def _update_method() -> int: + start.set() + await block.wait() + block.clear() + return 0 + + crd.update_method = _update_method + + # Add subscriber + update_callback = Mock() + crd.async_add_listener(update_callback) + + assert crd.update_interval + + # Trigger timed update, ensure it is started + freezer.tick(crd.update_interval) + async_fire_time_changed(hass) + await start.wait() + start.clear() + + # Trigger direct update + task = hass.async_create_background_task(crd.async_refresh(), "", eager_start=True) + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + + # Ensure it has not started + assert not start.is_set() + + # Unblock interval update + block.set() + + # Check that direct update starts + await start.wait() + start.clear() + + # Request update. This should not be blocking + # since the lock is held, it should be queued + await crd.async_request_refresh() + assert not start.is_set() + + # Unblock second update + block.set() + # Check that task finishes + await task + + # Check that queued update starts + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await start.wait() + start.clear() + + # Unblock queued update + block.set() + + async def test_refresh_recover( crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index ba4926eba6d4e6..26a6b1805201b4 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -675,6 +675,7 @@ PressureConverter: [ (1000, UnitOfPressure.HPA, 14.5037743897, UnitOfPressure.PSI), (1000, UnitOfPressure.HPA, 29.5299801647, UnitOfPressure.INHG), + (1000, UnitOfPressure.HPA, 100000000, UnitOfPressure.MILLIPASCAL), (1000, UnitOfPressure.HPA, 100000, UnitOfPressure.PA), (1000, UnitOfPressure.HPA, 100, UnitOfPressure.KPA), (1000, UnitOfPressure.HPA, 1000, UnitOfPressure.MBAR), @@ -682,6 +683,7 @@ (1000, UnitOfPressure.HPA, 401.46307866177, UnitOfPressure.INH2O), (100, UnitOfPressure.KPA, 14.5037743897, UnitOfPressure.PSI), (100, UnitOfPressure.KPA, 29.5299801647, UnitOfPressure.INHG), + (100, UnitOfPressure.KPA, 100000000, UnitOfPressure.MILLIPASCAL), (100, UnitOfPressure.KPA, 100000, UnitOfPressure.PA), (100, UnitOfPressure.KPA, 1000, UnitOfPressure.HPA), (100, UnitOfPressure.KPA, 1000, UnitOfPressure.MBAR), @@ -689,6 +691,7 @@ (100, UnitOfPressure.INH2O, 3.6127291827353996, UnitOfPressure.PSI), (100, UnitOfPressure.INH2O, 186.83201548767, UnitOfPressure.MMHG), (100, UnitOfPressure.INH2O, 7.3555912463681, UnitOfPressure.INHG), + (100, UnitOfPressure.INH2O, 24908890.833333, UnitOfPressure.MILLIPASCAL), (100, UnitOfPressure.INH2O, 24908.890833333, UnitOfPressure.PA), (100, UnitOfPressure.INH2O, 249.08890833333, UnitOfPressure.HPA), (100, UnitOfPressure.INH2O, 249.08890833333, UnitOfPressure.MBAR), @@ -698,6 +701,7 @@ (30, UnitOfPressure.INHG, 101.59167, UnitOfPressure.KPA), (30, UnitOfPressure.INHG, 1015.9167, UnitOfPressure.HPA), (30, UnitOfPressure.INHG, 101591.67, UnitOfPressure.PA), + (30, UnitOfPressure.INHG, 101591670, UnitOfPressure.MILLIPASCAL), (30, UnitOfPressure.INHG, 1015.9167, UnitOfPressure.MBAR), (30, UnitOfPressure.INHG, 101.59167, UnitOfPressure.CBAR), (30, UnitOfPressure.INHG, 762, UnitOfPressure.MMHG), @@ -706,6 +710,7 @@ (30, UnitOfPressure.MMHG, 3.99967, UnitOfPressure.KPA), (30, UnitOfPressure.MMHG, 39.9967, UnitOfPressure.HPA), (30, UnitOfPressure.MMHG, 3999.67, UnitOfPressure.PA), + (30, UnitOfPressure.MMHG, 3999670, UnitOfPressure.MILLIPASCAL), (30, UnitOfPressure.MMHG, 39.9967, UnitOfPressure.MBAR), (30, UnitOfPressure.MMHG, 3.99967, UnitOfPressure.CBAR), (30, UnitOfPressure.MMHG, 1.181102, UnitOfPressure.INHG), @@ -972,6 +977,12 @@ 7.48051948, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, ), + ( + 1, + UnitOfVolumeFlowRate.LITERS_PER_HOUR, + 0.264172052, + UnitOfVolumeFlowRate.GALLONS_PER_HOUR, + ), ( 9, UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index 54e9d4080e3ec0..c98611bd16a8d5 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -608,6 +608,7 @@ def test_get_metric_converted_unit_( UnitOfPressure.KPA, UnitOfPressure.MBAR, UnitOfPressure.MMHG, + UnitOfPressure.MILLIPASCAL, UnitOfPressure.PA, ), SensorDeviceClass.SPEED: (